diff --git a/ImageSharp.Drawing.sln b/ImageSharp.Drawing.sln
index 3f753f6c..68bd3309 100644
--- a/ImageSharp.Drawing.sln
+++ b/ImageSharp.Drawing.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 18
-VisualStudioVersion = 18.0.11123.170 d18.0
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36623.8 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_root", "_root", "{C317F1B1-D75E-4C6D-83EB-80367343E0D7}"
ProjectSection(SolutionItems) = preProject
@@ -359,14 +359,6 @@ Global
{5493F024-0A3F-420C-AC2D-05B77A36025B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5493F024-0A3F-420C-AC2D-05B77A36025B}.Release|Any CPU.Build.0 = Release|Any CPU
- {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {FCEDD229-22BC-4B82-87DE-786BBFC52EDE}.Release|Any CPU.Build.0 = Release|Any CPU
- {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5490DFAF-0891-535F-08B4-2BF03C2BB778}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/samples/DrawShapesWithImageSharp/Program.cs b/samples/DrawShapesWithImageSharp/Program.cs
index ed838547..e2862d48 100644
--- a/samples/DrawShapesWithImageSharp/Program.cs
+++ b/samples/DrawShapesWithImageSharp/Program.cs
@@ -6,6 +6,7 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Drawing.Text;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using IODirectory = System.IO.Directory;
@@ -61,7 +62,7 @@ private static void DrawText(string text)
FontFamily fam = SystemFonts.Get("Arial");
Font font = new(fam, 30);
TextOptions textOptions = new(font);
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, textOptions);
glyphs.SaveImage("Text", text + ".png");
}
@@ -80,7 +81,7 @@ private static void DrawText(string text, IPath path)
// LayoutMode = LayoutMode.VerticalLeftRight
};
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, path, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, path, textOptions);
glyphs.SaveImageWithPath(path, "Text-Path", text + ".png");
}
diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj
index da689136..91da382b 100644
--- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj
+++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj
@@ -44,8 +44,8 @@
-
-
+
+
\ No newline at end of file
diff --git a/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs b/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs
index bd92b071..fbe4233f 100644
--- a/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs
+++ b/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs
@@ -109,11 +109,11 @@ public RadialGradientBrushApplicator(
this.center = center;
this.referenceAxisEnd = referenceAxisEnd;
this.axisRatio = axisRatio;
- this.rotation = RadialGradientBrushApplicator.AngleBetween(
+ this.rotation = AngleBetween(
this.center,
new PointF(this.center.X + 1, this.center.Y),
this.referenceAxisEnd);
- this.referenceRadius = RadialGradientBrushApplicator.DistanceBetween(this.center, this.referenceAxisEnd);
+ this.referenceRadius = DistanceBetween(this.center, this.referenceAxisEnd);
this.secondRadius = this.referenceRadius * this.axisRatio;
this.referenceRadiusSquared = this.referenceRadius * this.referenceRadius;
diff --git a/src/ImageSharp.Drawing/Processing/Extensions/ClearExtensions.cs b/src/ImageSharp.Drawing/Processing/Extensions/ClearExtensions.cs
index 9540055a..f60e0f21 100644
--- a/src/ImageSharp.Drawing/Processing/Extensions/ClearExtensions.cs
+++ b/src/ImageSharp.Drawing/Processing/Extensions/ClearExtensions.cs
@@ -54,8 +54,8 @@ public static IImageProcessingContext Clear(this IImageProcessingContext source,
internal static DrawingOptions CloneForClearOperation(this DrawingOptions drawingOptions)
{
GraphicsOptions options = drawingOptions.GraphicsOptions.DeepClone();
- options.ColorBlendingMode = PixelFormats.PixelColorBlendingMode.Normal;
- options.AlphaCompositionMode = PixelFormats.PixelAlphaCompositionMode.Src;
+ options.ColorBlendingMode = PixelColorBlendingMode.Normal;
+ options.AlphaCompositionMode = PixelAlphaCompositionMode.Src;
options.BlendPercentage = 1F;
return new DrawingOptions(options, drawingOptions.ShapeOptions, drawingOptions.Transform);
diff --git a/src/ImageSharp.Drawing/Processing/Extensions/DrawPathCollectionExtensions.cs b/src/ImageSharp.Drawing/Processing/Extensions/DrawPathCollectionExtensions.cs
index 36c36c42..6726e2bf 100644
--- a/src/ImageSharp.Drawing/Processing/Extensions/DrawPathCollectionExtensions.cs
+++ b/src/ImageSharp.Drawing/Processing/Extensions/DrawPathCollectionExtensions.cs
@@ -38,8 +38,8 @@ public static IImageProcessingContext Draw(
/// The paths.
/// The to allow chaining of operations.
public static IImageProcessingContext
- Draw(this IImageProcessingContext source, Pen pen, IPathCollection paths) =>
- source.Draw(source.GetDrawingOptions(), pen, paths);
+ Draw(this IImageProcessingContext source, Pen pen, IPathCollection paths)
+ => source.Draw(source.GetDrawingOptions(), pen, paths);
///
/// Draws the outline of the polygon with the provided brush at the provided thickness.
diff --git a/src/ImageSharp.Drawing/Processing/Extensions/FillPathCollectionExtensions.cs b/src/ImageSharp.Drawing/Processing/Extensions/FillPathCollectionExtensions.cs
index 8a3c0b30..3b8cb4d8 100644
--- a/src/ImageSharp.Drawing/Processing/Extensions/FillPathCollectionExtensions.cs
+++ b/src/ImageSharp.Drawing/Processing/Extensions/FillPathCollectionExtensions.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.ImageSharp.Drawing.Text;
+
namespace SixLabors.ImageSharp.Drawing.Processing;
///
@@ -14,7 +16,7 @@ public static class FillPathCollectionExtensions
/// The source image processing context.
/// The graphics options.
/// The brush.
- /// The shapes.
+ /// The collection of paths.
/// The to allow chaining of operations.
public static IImageProcessingContext Fill(
this IImageProcessingContext source,
@@ -30,12 +32,120 @@ public static IImageProcessingContext Fill(
return source;
}
+ ///
+ /// Flood fills the image in the shape of the provided glyphs with the specified brush and pen.
+ /// For multi-layer glyphs, a heuristic is used to decide whether to fill or stroke each layer.
+ ///
+ /// The source image processing context.
+ /// The graphics options.
+ /// The brush.
+ /// The pen.
+ /// The collection of glyph paths.
+ /// The to allow chaining of operations.
+ public static IImageProcessingContext Fill(
+ this IImageProcessingContext source,
+ DrawingOptions options,
+ Brush brush,
+ Pen pen,
+ IReadOnlyList paths)
+ => source.Fill(options, brush, pen, paths, static (gp, layer, path) =>
+ {
+ if (layer.Kind == GlyphLayerKind.Decoration)
+ {
+ // Decorations (underlines, strikethroughs, etc) are always filled.
+ return true;
+ }
+
+ if (layer.Kind == GlyphLayerKind.Glyph)
+ {
+ // Standard glyph layers are filled by default.
+ return true;
+ }
+
+ // Default heuristic: stroke "background-like" layers (large coverage), fill others.
+ // Use the bounding box area as an approximation of the glyph area as it is cheaper to compute.
+ float glyphArea = gp.Bounds.Width * gp.Bounds.Height;
+ float layerArea = path.ComputeArea();
+
+ if (layerArea <= 0 || glyphArea <= 0)
+ {
+ return false; // degenerate glyph, don't fill
+ }
+
+ float coverage = layerArea / glyphArea;
+
+ // <50% coverage, fill. Otherwise, stroke.
+ return coverage < 0.50F;
+ });
+
+ ///
+ /// Flood fills the image in the shape of the provided glyphs with the specified brush and pen.
+ /// For multi-layer glyphs, a heuristic is used to decide whether to fill or stroke each layer.
+ ///
+ /// The source image processing context.
+ /// The graphics options.
+ /// The brush.
+ /// The pen.
+ /// The collection of glyph paths.
+ ///
+ /// A function that decides whether to fill or stroke a given layer within a multi-layer (painted) glyph.
+ ///
+ /// The to allow chaining of operations.
+ public static IImageProcessingContext Fill(
+ this IImageProcessingContext source,
+ DrawingOptions options,
+ Brush brush,
+ Pen pen,
+ IReadOnlyList paths,
+ Func shouldFillLayer)
+ {
+ foreach (GlyphPathCollection gp in paths)
+ {
+ if (gp.LayerCount == 0)
+ {
+ continue;
+ }
+
+ if (gp.LayerCount == 1)
+ {
+ // Single-layer glyph: just fill with the supplied brush.
+ source.Fill(options, brush, gp.Paths);
+ continue;
+ }
+
+ // Multi-layer: decide per layer whether to fill or stroke.
+ for (int i = 0; i < gp.Layers.Count; i++)
+ {
+ GlyphLayerInfo layer = gp.Layers[i];
+ IPath path = gp.PathList[i];
+
+ if (shouldFillLayer(gp, layer, path))
+ {
+ // Respect the layer's fill rule if different to the drawing options.
+ DrawingOptions o = options.CloneOrReturnForRules(
+ layer.IntersectionRule,
+ layer.PixelAlphaCompositionMode,
+ layer.PixelColorBlendingMode);
+
+ source.Fill(o, brush, path);
+ }
+ else
+ {
+ // Outline only to preserve interior detail.
+ source.Draw(options, pen, path);
+ }
+ }
+ }
+
+ return source;
+ }
+
///
/// Flood fills the image in the shape of the provided polygon with the specified brush.
///
/// The source image processing context.
/// The brush.
- /// The paths.
+ /// The collection of paths.
/// The to allow chaining of operations.
public static IImageProcessingContext Fill(
this IImageProcessingContext source,
@@ -44,7 +154,23 @@ public static IImageProcessingContext Fill(
source.Fill(source.GetDrawingOptions(), brush, paths);
///
- /// Flood fills the image in the shape of the provided polygon with the specified brush.
+ /// Flood fills the image in the shape of the provided glyphs with the specified brush and pen.
+ /// For multi-layer glyphs, a heuristic is used to decide whether to fill or stroke each layer.
+ ///
+ /// The source image processing context.
+ /// The brush.
+ /// The pen.
+ /// The collection of glyph paths.
+ /// The to allow chaining of operations.
+ public static IImageProcessingContext Fill(
+ this IImageProcessingContext source,
+ Brush brush,
+ Pen pen,
+ IReadOnlyList paths) =>
+ source.Fill(source.GetDrawingOptions(), brush, pen, paths);
+
+ ///
+ /// Flood fills the image in the shape of the provided polygon with the specified color.
///
/// The source image processing context.
/// The options.
@@ -59,15 +185,29 @@ public static IImageProcessingContext Fill(
source.Fill(options, new SolidBrush(color), paths);
///
- /// Flood fills the image in the shape of the provided polygon with the specified brush.
+ /// Flood fills the image in the shape of the provided polygon with the specified color.
///
/// The source image processing context.
/// The color.
- /// The paths.
+ /// The collection of paths.
/// The to allow chaining of operations.
public static IImageProcessingContext Fill(
this IImageProcessingContext source,
Color color,
IPathCollection paths) =>
source.Fill(new SolidBrush(color), paths);
+
+ ///
+ /// Flood fills the image in the shape of the provided glyphs with the specified color.
+ /// For multi-layer glyphs, a heuristic is used to decide whether to fill or stroke each layer.
+ ///
+ /// The source image processing context.
+ /// The color.
+ /// The collection of glyph paths.
+ /// The to allow chaining of operations.
+ public static IImageProcessingContext Fill(
+ this IImageProcessingContext source,
+ Color color,
+ IReadOnlyList paths) =>
+ source.Fill(new SolidBrush(color), new SolidPen(color), paths);
}
diff --git a/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs b/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs
index 4427abf2..22692dc0 100644
--- a/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs
+++ b/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs
@@ -4,31 +4,57 @@
namespace SixLabors.ImageSharp.Drawing.Processing;
///
-/// Provides an implementation of a brush for painting linear gradients within areas.
-/// Supported right now:
-/// - a set of colors in relative distances to each other.
+/// Provides a brush that paints linear gradients within an area.
+/// Supports both classic two-point gradients and three-point (rotated) gradients.
///
public sealed class LinearGradientBrush : GradientBrush
{
- private readonly PointF p1;
- private readonly PointF p2;
+ private readonly PointF startPoint;
+ private readonly PointF endPoint;
+ private readonly PointF? rotationPoint;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class using
+ /// a start and end point.
///
- /// Start point
- /// End point
- /// defines how colors are repeated.
- ///
+ /// The start point of the gradient.
+ /// The end point of the gradient.
+ /// Defines how the colors are repeated.
+ /// The ordered color stops of the gradient.
public LinearGradientBrush(
+ PointF p0,
PointF p1,
- PointF p2,
GradientRepetitionMode repetitionMode,
params ColorStop[] colorStops)
: base(repetitionMode, colorStops)
{
- this.p1 = p1;
- this.p2 = p2;
+ this.startPoint = p0;
+ this.endPoint = p1;
+ this.rotationPoint = null;
+ }
+
+ ///
+ /// Initializes a new instance of the class using
+ /// three points to define a rotated gradient axis.
+ ///
+ /// The first point (start of the gradient).
+ /// The second point (gradient vector endpoint).
+ ///
+ /// The rotation reference point. This defines the rotation of the gradient axis.
+ ///
+ /// Defines how the colors are repeated.
+ /// The ordered color stops of the gradient.
+ public LinearGradientBrush(
+ PointF p0,
+ PointF p1,
+ PointF rotationPoint,
+ GradientRepetitionMode repetitionMode,
+ params ColorStop[] colorStops)
+ : base(repetitionMode, colorStops)
+ {
+ this.startPoint = p0;
+ this.endPoint = p1;
+ this.rotationPoint = rotationPoint;
}
///
@@ -37,135 +63,165 @@ public override bool Equals(Brush? other)
if (other is LinearGradientBrush brush)
{
return base.Equals(other)
- && this.p1.Equals(brush.p1)
- && this.p2.Equals(brush.p2);
+ && this.startPoint.Equals(brush.startPoint)
+ && this.endPoint.Equals(brush.endPoint)
+ && Nullable.Equals(this.rotationPoint, brush.rotationPoint);
}
return false;
}
- ///
+ ///
+ public override int GetHashCode()
+ => HashCode.Combine(base.GetHashCode(), this.startPoint, this.endPoint, this.rotationPoint);
+
+ ///
public override BrushApplicator CreateApplicator(
Configuration configuration,
GraphicsOptions options,
ImageFrame source,
- RectangleF region) =>
- new LinearGradientBrushApplicator(
+ RectangleF region)
+ => new LinearGradientBrushApplicator(
configuration,
options,
source,
- this.p1,
- this.p2,
+ this.startPoint,
+ this.endPoint,
+ this.rotationPoint,
this.ColorStops,
this.RepetitionMode);
- ///
- public override int GetHashCode()
- => HashCode.Combine(base.GetHashCode(), this.p1, this.p2);
-
///
- /// The linear gradient brush applicator.
+ /// Implements the gradient application logic for .
///
/// The pixel format.
private sealed class LinearGradientBrushApplicator : GradientBrushApplicator
where TPixel : unmanaged, IPixel
{
private readonly PointF start;
-
private readonly PointF end;
-
- ///
- /// the vector along the gradient, x component
- ///
private readonly float alongX;
-
- ///
- /// the vector along the gradient, y component
- ///
private readonly float alongY;
-
- ///
- /// the vector perpendicular to the gradient, y component
- ///
- private readonly float acrossY;
-
- ///
- /// the vector perpendicular to the gradient, x component
- ///
private readonly float acrossX;
-
- ///
- /// the result of ^2 + ^2
- ///
+ private readonly float acrossY;
private readonly float alongsSquared;
-
- ///
- /// the length of the defined gradient (between source and end)
- ///
private readonly float length;
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// The configuration instance to use when performing operations.
+ /// The ImageSharp configuration.
/// The graphics options.
- /// The source image.
- /// The start point of the gradient.
- /// The end point of the gradient.
- /// A tuple list of colors and their respective position between 0 and 1 on the line.
- /// Defines how the gradient colors are repeated.
+ /// The target image frame.
+ /// The gradient start point.
+ /// The gradient end point.
+ /// The optional rotation point.
+ /// The gradient color stops.
+ /// Defines how the gradient repeats.
public LinearGradientBrushApplicator(
Configuration configuration,
GraphicsOptions options,
ImageFrame source,
- PointF start,
- PointF end,
+ PointF p0,
+ PointF p1,
+ PointF? p2,
ColorStop[] colorStops,
GradientRepetitionMode repetitionMode)
: base(configuration, options, source, colorStops, repetitionMode)
{
- this.start = start;
- this.end = end;
+ // Determine whether this is a simple linear gradient (2 points)
+ // or a rotated one (3 points).
+ if (p2 is null)
+ {
+ // Classic SVG-style gradient from start -> end.
+ this.start = p0;
+ this.end = p1;
+ }
+ else
+ {
+ // Compute the rotated gradient axis per COLRv1 rules.
+ // p0 = start, p1 = gradient vector, p2 = rotation reference.
+ float vx = p1.X - p0.X;
+ float vy = p1.Y - p0.Y;
+ float rx = p2.Value.X - p0.X;
+ float ry = p2.Value.Y - p0.Y;
+
+ // n = perpendicular to rotation vector
+ float nx = ry;
+ float ny = -rx;
+
+ // Avoid divide-by-zero if p0 == p2.
+ float ndotn = (nx * nx) + (ny * ny);
+ if (ndotn == 0f)
+ {
+ this.start = p0;
+ this.end = p1;
+ }
+ else
+ {
+ // Project p1 - p0 onto perpendicular direction.
+ float vdotn = (vx * nx) + (vy * ny);
+ float scale = vdotn / ndotn;
+
+ // The derived endpoint after rotation.
+ this.start = p0;
+ this.end = new PointF(p0.X + (scale * nx), p0.Y + (scale * ny));
+ }
+ }
- // the along vector:
+ // Calculate axis vectors.
this.alongX = this.end.X - this.start.X;
this.alongY = this.end.Y - this.start.Y;
- // the cross vector:
+ // Perpendicular axis vector.
this.acrossX = this.alongY;
this.acrossY = -this.alongX;
- // some helpers:
+ // Precompute squared length and actual length for later use.
this.alongsSquared = (this.alongX * this.alongX) + (this.alongY * this.alongY);
this.length = MathF.Sqrt(this.alongsSquared);
}
+ ///
protected override float PositionOnGradient(float x, float y)
{
- if (this.acrossX == 0)
+ // Degenerate case: gradient length == 0, use final stop color.
+ if (this.alongsSquared == 0f)
{
- return (x - this.start.X) / (this.end.X - this.start.X);
+ return 1f;
}
- else if (this.acrossY == 0)
+
+ // Fast path for horizontal gradients.
+ if (this.acrossX == 0f)
{
- return (y - this.start.Y) / (this.end.Y - this.start.Y);
+ float denom = this.end.X - this.start.X;
+ return denom != 0f ? (x - this.start.X) / denom : 1f;
}
- else
+
+ // Fast path for vertical gradients.
+ if (this.acrossY == 0f)
{
- float deltaX = x - this.start.X;
- float deltaY = y - this.start.Y;
- float k = ((this.alongY * deltaX) - (this.alongX * deltaY)) / this.alongsSquared;
+ float denom = this.end.Y - this.start.Y;
+ return denom != 0f ? (y - this.start.Y) / denom : 1f;
+ }
- // point on the line:
- float x4 = x - (k * this.alongY);
- float y4 = y + (k * this.alongX);
+ // General case: project sample point onto the gradient axis.
+ float deltaX = x - this.start.X;
+ float deltaY = y - this.start.Y;
- // get distance from (x4,y4) to start
- float distance = MathF.Sqrt(MathF.Pow(x4 - this.start.X, 2) + MathF.Pow(y4 - this.start.Y, 2));
+ // Compute perpendicular projection scalar.
+ float k = ((this.alongY * deltaX) - (this.alongX * deltaY)) / this.alongsSquared;
- // get and return ratio
- return distance / this.length;
- }
+ // Determine projected point on the gradient line.
+ float projX = x - (k * this.alongY);
+ float projY = y + (k * this.alongX);
+
+ // Compute distance from gradient start to projected point.
+ float dx = projX - this.start.X;
+ float dy = projY - this.start.Y;
+
+ // Normalize to [0,1] range along the gradient length.
+ return this.length > 0f ? MathF.Sqrt((dx * dx) + (dy * dy)) / this.length : 1f;
}
}
}
diff --git a/src/ImageSharp.Drawing/Processing/Processors/Text/DrawTextProcessor{TPixel}.cs b/src/ImageSharp.Drawing/Processing/Processors/Text/DrawTextProcessor{TPixel}.cs
index 65421276..e1a6e09a 100644
--- a/src/ImageSharp.Drawing/Processing/Processors/Text/DrawTextProcessor{TPixel}.cs
+++ b/src/ImageSharp.Drawing/Processing/Processors/Text/DrawTextProcessor{TPixel}.cs
@@ -2,7 +2,8 @@
// Licensed under the Six Labors Split License.
using System.Numerics;
-using SixLabors.Fonts;
+using SixLabors.Fonts.Rendering;
+using SixLabors.ImageSharp.Drawing.Text;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Processing.Processors;
@@ -55,9 +56,14 @@ void Draw(IEnumerable operations)
{
foreach (DrawingOperation operation in operations)
{
+ GraphicsOptions graphicsOptions =
+ this.definition.DrawingOptions.GraphicsOptions.CloneOrReturnForRules(
+ operation.PixelAlphaCompositionMode,
+ operation.PixelColorBlendingMode);
+
using BrushApplicator app = operation.Brush.CreateApplicator(
this.Configuration,
- this.definition.DrawingOptions.GraphicsOptions,
+ graphicsOptions,
source,
this.SourceRectangle);
@@ -93,13 +99,14 @@ void Draw(IEnumerable operations)
firstRow = -startY;
}
+ int maxWidth = source.Width - startX;
int maxHeight = source.Height - startY;
int end = Math.Min(operation.Map.Height, maxHeight);
for (int row = firstRow; row < end; row++)
{
int y = startY + row;
- Span span = buffer.DangerousGetRowSpan(row).Slice(offsetSpan, Math.Min(buffer.Width - offsetSpan, source.Width));
+ Span span = buffer.DangerousGetRowSpan(row).Slice(offsetSpan, Math.Min(buffer.Width - offsetSpan, maxWidth));
app.Apply(span, startX, y);
}
}
diff --git a/src/ImageSharp.Drawing/Processing/Processors/Text/DrawingOperation.cs b/src/ImageSharp.Drawing/Processing/Processors/Text/DrawingOperation.cs
index 901ed8f1..1914f4bb 100644
--- a/src/ImageSharp.Drawing/Processing/Processors/Text/DrawingOperation.cs
+++ b/src/ImageSharp.Drawing/Processing/Processors/Text/DrawingOperation.cs
@@ -16,4 +16,8 @@ internal struct DrawingOperation
public Point RenderLocation { get; set; }
public Brush Brush { get; internal set; }
+
+ public PixelAlphaCompositionMode PixelAlphaCompositionMode { get; set; }
+
+ public PixelColorBlendingMode PixelColorBlendingMode { get; set; }
}
diff --git a/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.Brushes.cs b/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.Brushes.cs
new file mode 100644
index 00000000..f9e04483
--- /dev/null
+++ b/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.Brushes.cs
@@ -0,0 +1,196 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using SixLabors.Fonts;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.ImageSharp.Drawing.Processing.Processors.Text;
+
+///
+/// Utilities to translate format-agnostic paints (from Fonts) into ImageSharp.Drawing brushes.
+///
+internal sealed partial class RichTextGlyphRenderer
+{
+ ///
+ /// Attempts to create an ImageSharp.Drawing from a .
+ ///
+ /// The paint definition coming from the interpreter.
+ /// A transform to apply to the brush coordinates.
+ /// The resulting brush, or if the paint is unsupported.
+ /// if a brush could be created; otherwise, .
+ public static bool TryCreateBrush([NotNullWhen(true)] Paint? paint, Matrix3x2 transform, [NotNullWhen(true)] out Brush? brush)
+ {
+ brush = null;
+
+ if (paint is null)
+ {
+ return false;
+ }
+
+ switch (paint)
+ {
+ case SolidPaint sp:
+ brush = new SolidBrush(ToColor(sp.Color, sp.Opacity));
+ return true;
+
+ case LinearGradientPaint lg:
+ return TryCreateLinearGradientBrush(lg, transform, out brush);
+ case RadialGradientPaint rg:
+ return TryCreateRadialGradientBrush(rg, transform, out brush);
+ case SweepGradientPaint sg:
+ return TryCreateSweepGradientBrush(sg, transform, out brush);
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// Creates a from a .
+ ///
+ /// The linear gradient paint.
+ /// The transform to apply to the gradient points.
+ /// The resulting brush.
+ /// if created; otherwise, .
+ private static bool TryCreateLinearGradientBrush(LinearGradientPaint paint, Matrix3x2 transform, out Brush? brush)
+ {
+ // Map gradient stops (apply paint opacity multiplier to each stop's alpha).
+ ColorStop[] stops = ToColorStops(paint.Stops, paint.Opacity);
+
+ // Map spread method.
+ GradientRepetitionMode mode = MapSpread(paint.Spread);
+
+ PointF p0 = paint.P0;
+ PointF p1 = paint.P1;
+ PointF? p2 = paint.P2;
+
+ // Apply any transform defined on the paint.
+ if (!transform.IsIdentity)
+ {
+ p0 = Vector2.Transform(p0, transform);
+ p1 = Vector2.Transform(p1, transform);
+
+ if (p2.HasValue)
+ {
+ p2 = Vector2.Transform(p2.Value, transform);
+ }
+ }
+
+ if (p2.HasValue)
+ {
+ brush = new LinearGradientBrush(p0, p1, p2.Value, mode, stops);
+ return true;
+ }
+
+ brush = new LinearGradientBrush(p0, p1, mode, stops);
+ return true;
+ }
+
+ ///
+ /// Creates a from a .
+ ///
+ /// The radial gradient paint.
+ /// The transform to apply to the gradient center point.
+ /// The resulting brush.
+ /// if created; otherwise, .
+ private static bool TryCreateRadialGradientBrush(RadialGradientPaint paint, Matrix3x2 transform, out Brush? brush)
+ {
+ // Map gradient stops (apply paint opacity multiplier to each stop's alpha).
+ ColorStop[] stops = ToColorStops(paint.Stops, paint.Opacity);
+
+ // Map spread method.
+ GradientRepetitionMode mode = MapSpread(paint.Spread);
+
+ // Apply any transform defined on the paint.
+ PointF center0 = paint.Center0;
+ PointF center1 = paint.Center1;
+ if (!transform.IsIdentity)
+ {
+ center0 = Vector2.Transform(center0, transform);
+ center1 = Vector2.Transform(center1, transform);
+ }
+
+ brush = new RadialGradientBrush(center0, paint.Radius0, center1, paint.Radius1, mode, stops);
+ return true;
+ }
+
+ ///
+ /// Creates a from a .
+ ///
+ /// The sweep gradient paint.
+ /// The transform to apply to the gradient center point.
+ /// The resulting brush.
+ /// if created; otherwise, .
+ private static bool TryCreateSweepGradientBrush(SweepGradientPaint paint, Matrix3x2 transform, out Brush? brush)
+ {
+ // Map gradient stops (apply paint opacity multiplier to each stop's alpha).
+ ColorStop[] stops = ToColorStops(paint.Stops, paint.Opacity);
+
+ // Map spread method.
+ GradientRepetitionMode mode = MapSpread(paint.Spread);
+
+ // Apply any transform defined on the paint.
+ PointF center = paint.Center;
+ if (!transform.IsIdentity)
+ {
+ center = Vector2.Transform(center, transform);
+ }
+
+ brush = new SweepGradientBrush(center, paint.StartAngle, paint.EndAngle, mode, stops);
+ return true;
+ }
+
+ ///
+ /// Maps an to .
+ ///
+ /// The spread method.
+ /// The repetition mode.
+ private static GradientRepetitionMode MapSpread(SpreadMethod spread)
+ => spread switch
+ {
+ SpreadMethod.Reflect => GradientRepetitionMode.Reflect,
+ SpreadMethod.Repeat => GradientRepetitionMode.Repeat,
+
+ // Pad extends edge colors, which matches 'None' (not 'DontFill').
+ _ => GradientRepetitionMode.None,
+ };
+
+ ///
+ /// Converts gradient stops and applies a paint opacity multiplier.
+ ///
+ /// The source stops.
+ /// The paint opacity in range [0,1].
+ /// An array of .
+ private static ColorStop[] ToColorStops(ReadOnlySpan stops, float paintOpacity)
+ {
+ if (stops.Length == 0)
+ {
+ return [];
+ }
+
+ ColorStop[] result = new ColorStop[stops.Length];
+
+ for (int i = 0; i < stops.Length; i++)
+ {
+ GradientStop s = stops[i];
+ Color c = ToColor(s.Color, paintOpacity);
+ result[i] = new ColorStop(s.Offset, c);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts a with an additional opacity multiplier to ImageSharp .
+ ///
+ /// The glyph color.
+ /// The opacity multiplier in range [0,1].
+ /// The ImageSharp color.
+ private static Color ToColor(in GlyphColor c, float opacity)
+ {
+ float a = Math.Clamp(c.A / 255f * Math.Clamp(opacity, 0f, 1f), 0f, 1f);
+ byte aa = (byte)MathF.Round(a * 255f);
+ return Color.FromPixel(new Rgba32(c.R, c.G, c.B, aa));
+ }
+}
diff --git a/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs b/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs
index db239525..ab29fc80 100644
--- a/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs
+++ b/src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs
@@ -4,6 +4,8 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts;
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp.Drawing.Processing.Processors.Drawing;
using SixLabors.ImageSharp.Drawing.Shapes.Rasterization;
using SixLabors.ImageSharp.Drawing.Text;
@@ -14,7 +16,7 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Processors.Text;
///
/// Allows the rendering of rich text configured via .
///
-internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRenderer, IDisposable
+internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder, IDisposable
{
private const byte RenderOrderFill = 0;
private const byte RenderOrderOutline = 1;
@@ -27,15 +29,14 @@ internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRende
private readonly IPathInternals? path;
private bool isDisposed;
- private readonly Dictionary brushLookup = new();
private TextRun? currentTextRun;
private Brush? currentBrush;
private Pen? currentPen;
- private Color? currentColor;
- private TextDecorationDetails? currentUnderline;
- private TextDecorationDetails? currentStrikeout;
- private TextDecorationDetails? currentOverline;
+ private FillRule currentFillRule;
+ private PixelAlphaCompositionMode currentCompositionMode;
+ private PixelColorBlendingMode currentBlendingMode;
private bool currentDecorationIsVertical;
+ private bool hasLayer;
// Just enough accuracy to allow for 1/8 px differences which later are accumulated while rendering,
// but do not grow into full px offsets.
@@ -43,10 +44,12 @@ internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRende
// - Provide a good accuracy (smaller than 0.2% image difference compared to the non-caching variant)
// - Cache hit ratio above 60%
private const float AccuracyMultiple = 8;
- private readonly Dictionary<(GlyphRendererParameters Glyph, RectangleF Bounds), GlyphRenderData> glyphData = new();
+ private readonly Dictionary> glyphCache = [];
+ private int cacheReadIndex;
+
private bool rasterizationRequired;
private readonly bool noCache;
- private (GlyphRendererParameters Glyph, RectangleF Bounds) currentCacheKey;
+ private CacheKey currentCacheKey;
public RichTextGlyphRenderer(
RichTextOptions textOptions,
@@ -61,11 +64,13 @@ public RichTextGlyphRenderer(
this.defaultPen = pen;
this.defaultBrush = brush;
this.DrawingOperations = [];
+ this.currentCompositionMode = drawingOptions.GraphicsOptions.AlphaCompositionMode;
+ this.currentBlendingMode = drawingOptions.GraphicsOptions.ColorBlendingMode;
IPath? path = textOptions.Path;
if (path is not null)
{
- // Turn of caching. The chances of a hit are near-zero.
+ // Turn off caching. The chances of a hit are near-zero.
this.rasterizationRequired = true;
this.noCache = true;
if (path is IPathInternals internals)
@@ -95,7 +100,8 @@ protected override void BeginText(in FontRectangle bounds)
///
protected override void BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
{
- this.currentColor = null;
+ // Reset state.
+ this.cacheReadIndex = 0;
this.currentDecorationIsVertical = parameters.LayoutMode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated;
this.currentTextRun = parameters.TextRun;
if (parameters.TextRun is RichTextRun drawingRun)
@@ -127,8 +133,8 @@ protected override void BeginGlyph(in FontRectangle bounds, in GlyphRendererPara
MathF.Round(currentBounds.Width * AccuracyMultiple) / AccuracyMultiple,
MathF.Round(currentBounds.Height * AccuracyMultiple) / AccuracyMultiple);
- this.currentCacheKey = (parameters, new RectangleF(subPixelLocation, subPixelSize));
- if (this.glyphData.ContainsKey(this.currentCacheKey))
+ this.currentCacheKey = CacheKey.FromParameters(parameters, new RectangleF(subPixelLocation, subPixelSize));
+ if (this.glyphCache.ContainsKey(this.currentCacheKey))
{
// We have already drawn the glyph vectors.
this.rasterizationRequired = false;
@@ -142,9 +148,107 @@ protected override void BeginGlyph(in FontRectangle bounds, in GlyphRendererPara
this.rasterizationRequired = true;
}
- ///
- public void SetColor(GlyphColor color)
- => this.currentColor = Color.FromPixel(new Rgba32(color.Red, color.Green, color.Blue, color.Alpha));
+ protected override void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds)
+ {
+ this.hasLayer = true;
+ if (TryCreateBrush(paint, this.Builder.Transform, out Brush? brush))
+ {
+ this.currentBrush = brush;
+ this.currentCompositionMode = TextUtilities.MapCompositionMode(paint.CompositeMode);
+ this.currentBlendingMode = TextUtilities.MapBlendingMode(paint.CompositeMode);
+ }
+ }
+
+ protected override void EndLayer()
+ {
+ GlyphRenderData renderData = default;
+
+ // Fix up the text runs colors.
+ // Only if both brush and pen is null do we fallback to the default value.
+ if (this.currentBrush == null && this.currentPen == null)
+ {
+ this.currentBrush = this.defaultBrush;
+ this.currentPen = this.defaultPen;
+ }
+
+ // When rendering layers we only fill them.
+ // Any drawing of outlines is ignored as that doesn't really make sense.
+ bool renderFill = this.currentBrush != null;
+
+ // Path has already been added to the collection via the base class.
+ IPath path = this.CurrentPaths[^1];
+ Point renderLocation = ClampToPixel(path.Bounds.Location);
+ if (this.noCache || this.rasterizationRequired)
+ {
+ if (path.Bounds.Equals(RectangleF.Empty))
+ {
+ return;
+ }
+
+ if (renderFill)
+ {
+ renderData.FillMap = this.Render(path);
+ }
+
+ // Capture the delta between the location and the truncated render location.
+ // We can use this to offset the render location on the next instance of this glyph.
+ renderData.LocationDelta = (Vector2)(path.Bounds.Location - renderLocation);
+
+ if (!this.noCache)
+ {
+ this.UpdateCache(renderData);
+ }
+ }
+ else
+ {
+ renderData = this.glyphCache[this.currentCacheKey][this.cacheReadIndex++];
+
+ // Offset the render location by the delta from the cached glyph and this one.
+ Vector2 previousDelta = renderData.LocationDelta;
+ Vector2 currentLocation = path.Bounds.Location;
+ Vector2 currentDelta = path.Bounds.Location - ClampToPixel(path.Bounds.Location);
+
+ if (previousDelta.Y > currentDelta.Y)
+ {
+ // Move the location down to match the previous location offset.
+ currentLocation += new Vector2(0, previousDelta.Y - currentDelta.Y);
+ }
+ else if (previousDelta.Y < currentDelta.Y)
+ {
+ // Move the location up to match the previous location offset.
+ currentLocation -= new Vector2(0, currentDelta.Y - previousDelta.Y);
+ }
+ else if (previousDelta.X > currentDelta.X)
+ {
+ // Move the location right to match the previous location offset.
+ currentLocation += new Vector2(previousDelta.X - currentDelta.X, 0);
+ }
+ else if (previousDelta.X < currentDelta.X)
+ {
+ // Move the location left to match the previous location offset.
+ currentLocation -= new Vector2(currentDelta.X - previousDelta.X, 0);
+ }
+
+ renderLocation = ClampToPixel(currentLocation);
+ }
+
+ if (renderData.FillMap != null)
+ {
+ this.DrawingOperations.Add(new DrawingOperation
+ {
+ RenderLocation = renderLocation,
+ Map = renderData.FillMap,
+ Brush = this.currentBrush!,
+ RenderPass = RenderOrderFill,
+ PixelAlphaCompositionMode = this.currentCompositionMode,
+ PixelColorBlendingMode = this.currentBlendingMode
+ });
+ }
+
+ this.currentFillRule = FillRule.NonZero;
+ this.currentCompositionMode = this.drawingOptions.GraphicsOptions.AlphaCompositionMode;
+ this.currentBlendingMode = this.drawingOptions.GraphicsOptions.ColorBlendingMode;
+ }
public override TextDecorations EnabledDecorations()
{
@@ -179,27 +283,12 @@ public override void SetDecoration(TextDecorations textDecorations, Vector2 star
return;
}
- ref TextDecorationDetails? targetDecoration = ref this.currentStrikeout;
- if (textDecorations == TextDecorations.Strikeout)
- {
- targetDecoration = ref this.currentStrikeout;
- }
- else if (textDecorations == TextDecorations.Underline)
- {
- targetDecoration = ref this.currentUnderline;
- }
- else if (textDecorations == TextDecorations.Overline)
- {
- targetDecoration = ref this.currentOverline;
- }
- else
- {
- return;
- }
-
+ Brush? brush = null;
Pen? pen = null;
if (this.currentTextRun is RichTextRun drawingRun)
{
+ brush = drawingRun.Brush;
+
if (textDecorations == TextDecorations.Strikeout)
{
pen = drawingRun.StrikeoutPen ?? pen;
@@ -215,44 +304,76 @@ public override void SetDecoration(TextDecorations textDecorations, Vector2 star
}
// Always respect the pen stroke width if explicitly set.
+ float originalThickness = thickness;
if (pen is not null)
{
- thickness = pen.StrokeWidth;
+ // Clamp the thickness to whole pixels.
+ thickness = MathF.Max(1F, (float)Math.Round(pen.StrokeWidth));
}
else
{
- // Clamp the thickness to whole pixels.
- // Brush cannot be null if pen is null.
- thickness = MathF.Max(1F, MathF.Round(thickness));
- pen = new SolidPen((this.currentBrush ?? this.defaultBrush)!, thickness);
+ // The thickness of the line has already been clamped in the base class.
+ pen = new SolidPen((brush ?? this.defaultBrush)!, thickness);
}
- // Drawing is always centered around the point so we need to offset by half.
- Vector2 offset = Vector2.Zero;
- bool rotated = this.currentDecorationIsVertical;
- if (textDecorations == TextDecorations.Overline)
- {
- // CSS overline is drawn above the position, so we need to move it up.
- offset = rotated ? new Vector2(thickness * .5F, 0) : new Vector2(0, -(thickness * .5F));
- }
- else if (textDecorations == TextDecorations.Underline)
+ // Path has already been added to the collection via the base class.
+ IPath path = this.CurrentPaths[^1];
+ IPath outline = path;
+
+ if (originalThickness != thickness)
{
- // CSS underline is drawn below the position, so we need to move it down.
- offset = rotated ? new Vector2(-(thickness * .5F), 0) : new Vector2(0, thickness * .5F);
+ // Respect edge anchoring per decoration type:
+ // - Overline: keep the base edge fixed (bottom in horizontal; left in vertical)
+ // - Underline: keep the top edge fixed (top in horizontal; right in vertical)
+ // - Strikeout: keep the center fixed (default behavior)
+ float ratio = thickness / originalThickness;
+ if (ratio != 1f)
+ {
+ Vector2 scale = this.currentDecorationIsVertical
+ ? new Vector2(ratio, 1f)
+ : new Vector2(1f, ratio);
+
+ RectangleF b = path.Bounds;
+ Vector2 center = new(b.Left + (b.Width * 0.5f), b.Top + (b.Height * 0.5f));
+ Vector2 anchor = center;
+
+ if (textDecorations == TextDecorations.Overline)
+ {
+ anchor = this.currentDecorationIsVertical
+ ? new Vector2(b.Left, center.Y) // vertical: anchor left edge
+ : new Vector2(center.X, b.Bottom); // horizontal: anchor bottom edge
+ }
+ else if (textDecorations == TextDecorations.Underline)
+ {
+ anchor = this.currentDecorationIsVertical
+ ? new Vector2(b.Right, center.Y) // vertical: anchor right edge
+ : new Vector2(center.X, b.Top); // horizontal: anchor top edge
+ }
+
+ // Scale about the chosen anchor so the fixed edge stays in place.
+ outline = outline.Transform(Matrix3x2.CreateScale(scale, anchor));
+ }
}
- // We clamp the start and end points to the pixel grid to avoid anti-aliasing.
- this.AppendDecoration(
- ref targetDecoration,
- ClampToPixel(start + offset, (int)thickness, rotated),
- ClampToPixel(end + offset, (int)thickness, rotated),
- pen,
- thickness,
- rotated);
+ // Render the path here. Decorations are un-cached.
+ this.DrawingOperations.Add(new DrawingOperation
+ {
+ Brush = pen.StrokeFill,
+ RenderLocation = ClampToPixel(outline.Bounds.Location),
+ Map = this.Render(outline),
+ RenderPass = RenderOrderDecoration
+ });
}
protected override void EndGlyph()
{
+ if (this.hasLayer)
+ {
+ // The layer has already been rendered.
+ this.hasLayer = false;
+ return;
+ }
+
GlyphRenderData renderData = default;
// Fix up the text runs colors.
@@ -268,31 +389,18 @@ protected override void EndGlyph()
// If we are using the fonts color layers we ignore the request to draw an outline only
// because that won't really work. Instead we force drawing using fill with the requested color.
- // If color fonts are disabled then this.currentColor will always be null.
- if (this.currentBrush != null || this.currentColor != null)
+ if (this.currentBrush != null)
{
renderFill = true;
- if (this.currentColor.HasValue)
- {
- if (this.brushLookup.TryGetValue(this.currentColor.Value, out Brush? brush))
- {
- this.currentBrush = brush;
- }
- else
- {
- this.currentBrush = new SolidBrush(this.currentColor.Value);
- this.brushLookup[this.currentColor.Value] = this.currentBrush;
- }
- }
}
- if (this.currentPen != null && this.currentColor == null)
+ if (this.currentPen != null)
{
renderOutline = true;
}
// Path has already been added to the collection via the base class.
- IPath path = this.PathList[^1];
+ IPath path = this.CurrentPaths[^1];
Point renderLocation = ClampToPixel(path.Bounds.Location);
if (this.noCache || this.rasterizationRequired)
{
@@ -306,24 +414,24 @@ protected override void EndGlyph()
renderData.FillMap = this.Render(path);
}
+ // Capture the delta between the location and the truncated render location.
+ // We can use this to offset the render location on the next instance of this glyph.
+ renderData.LocationDelta = (Vector2)(path.Bounds.Location - renderLocation);
+
if (renderOutline)
{
path = this.currentPen!.GeneratePath(path);
renderData.OutlineMap = this.Render(path);
}
- // Capture the delta between the location and the truncated render location.
- // We can use this to offset the render location on the next instance of this glyph.
- renderData.LocationDelta = (Vector2)(path.Bounds.Location - renderLocation);
-
if (!this.noCache)
{
- this.glyphData[this.currentCacheKey] = renderData;
+ this.UpdateCache(renderData);
}
}
else
{
- renderData = this.glyphData[this.currentCacheKey];
+ renderData = this.glyphCache[this.currentCacheKey][this.cacheReadIndex++];
// Offset the render location by the delta from the cached glyph and this one.
Vector2 previousDelta = renderData.LocationDelta;
@@ -361,7 +469,9 @@ protected override void EndGlyph()
RenderLocation = renderLocation,
Map = renderData.FillMap,
Brush = this.currentBrush!,
- RenderPass = RenderOrderFill
+ RenderPass = RenderOrderFill,
+ PixelAlphaCompositionMode = this.currentCompositionMode,
+ PixelColorBlendingMode = this.currentBlendingMode
});
}
@@ -373,126 +483,27 @@ protected override void EndGlyph()
RenderLocation = renderLocation - new Size(offset, offset),
Map = renderData.OutlineMap,
Brush = this.currentPen?.StrokeFill ?? this.currentBrush!,
- RenderPass = RenderOrderOutline
+ RenderPass = RenderOrderOutline,
+ PixelAlphaCompositionMode = this.currentCompositionMode,
+ PixelColorBlendingMode = this.currentBlendingMode
});
}
}
- protected override void EndText()
+ private void UpdateCache(GlyphRenderData renderData)
{
- // Ensure we have captured the last overline/underline/strikeout path
- this.FinalizeDecoration(ref this.currentOverline);
- this.FinalizeDecoration(ref this.currentUnderline);
- this.FinalizeDecoration(ref this.currentStrikeout);
- }
-
- public void Dispose() => this.Dispose(true);
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static Point ClampToPixel(PointF point) => Point.Truncate(point);
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static PointF ClampToPixel(PointF point, int thickness, bool rotated)
- {
- // Even. Clamp to whole pixels.
- if ((thickness & 1) == 0)
+ if (!this.glyphCache.TryGetValue(this.currentCacheKey, out List? _))
{
- return Point.Truncate(point);
+ this.glyphCache[this.currentCacheKey] = [];
}
- // Odd. Clamp to half pixels.
- if (rotated)
- {
- return Point.Truncate(point) + new Vector2(.5F, 0);
- }
-
- return Point.Truncate(point) + new Vector2(0, .5F);
- }
-
- // Point.Truncate(point);
- private void FinalizeDecoration(ref TextDecorationDetails? decoration)
- {
- if (decoration != null)
- {
- // TODO: If the path is curved a line segment does not work well.
- // What would be great would be if we could take a slice of a path given start and end positions.
- IPath path = new Path(new LinearLineSegment(decoration.Value.Start, decoration.Value.End));
- IPath outline = decoration.Value.Pen.GeneratePath(path, decoration.Value.Thickness);
-
- // Calculate the transform for this path.
- // We cannot use the path builder transform as this path is rendered independently.
- FontRectangle rectangle = new(outline.Bounds.Location, new Vector2(outline.Bounds.Width, outline.Bounds.Height));
- Matrix3x2 pathTransform = this.ComputeTransform(in rectangle);
- Matrix3x2 defaultTransform = this.drawingOptions.Transform;
- outline = outline.Transform(pathTransform * defaultTransform);
-
- if (outline.Bounds.Width != 0 && outline.Bounds.Height != 0)
- {
- // Render the path here. Decorations are un-cached.
- this.DrawingOperations.Add(new DrawingOperation
- {
- Brush = decoration.Value.Pen.StrokeFill,
- RenderLocation = ClampToPixel(outline.Bounds.Location),
- Map = this.Render(outline),
- RenderPass = RenderOrderDecoration
- });
- }
-
- decoration = null;
- }
+ this.glyphCache[this.currentCacheKey].Add(renderData);
}
- private void AppendDecoration(
- ref TextDecorationDetails? decoration,
- Vector2 start,
- Vector2 end,
- Pen pen,
- float thickness,
- bool rotated)
- {
- if (decoration != null)
- {
- // TODO: This only works well if we are not trying to follow a path.
- if (this.path is null)
- {
- // Let's try and expand it first.
- if (rotated)
- {
- if (thickness == decoration.Value.Thickness
- && decoration.Value.End.Y + 1 >= start.Y
- && decoration.Value.End.X == start.X
- && decoration.Value.Pen.Equals(pen))
- {
- // Expand the line
- start = decoration.Value.Start;
-
- // If this is null finalize does nothing.
- decoration = null;
- }
- }
- else if (thickness == decoration.Value.Thickness
- && decoration.Value.End.Y == start.Y
- && decoration.Value.End.X + 1 >= start.X
- && decoration.Value.Pen.Equals(pen))
- {
- // Expand the line
- start = decoration.Value.Start;
-
- // If this is null finalize does nothing.
- decoration = null;
- }
- }
- }
+ public void Dispose() => this.Dispose(true);
- this.FinalizeDecoration(ref decoration);
- decoration = new TextDecorationDetails
- {
- Start = start,
- End = end,
- Pen = pen,
- Thickness = thickness
- };
- }
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static Point ClampToPixel(PointF point) => Point.Truncate(point);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void TransformGlyph(in FontRectangle bounds)
@@ -543,7 +554,7 @@ private Buffer2D Render(IPath path)
0,
size.Height,
subpixelCount,
- IntersectionRule.NonZero,
+ TextUtilities.MapFillRule(this.currentFillRule),
this.memoryAllocator);
try
@@ -584,12 +595,15 @@ private void Dispose(bool disposing)
{
if (disposing)
{
- foreach (KeyValuePair<(GlyphRendererParameters Glyph, RectangleF Bounds), GlyphRenderData> kv in this.glyphData)
+ foreach (KeyValuePair> kv in this.glyphCache)
{
- kv.Value.Dispose();
+ foreach (GlyphRenderData data in kv.Value)
+ {
+ data.Dispose();
+ }
}
- this.glyphData.Clear();
+ this.glyphCache.Clear();
foreach (DrawingOperation operation in this.DrawingOperations)
{
@@ -618,14 +632,92 @@ public readonly void Dispose()
}
}
- private struct TextDecorationDetails
+ private readonly struct CacheKey : IEquatable
{
- public Vector2 Start { get; set; }
+ public string Font { get; init; }
+
+ public GlyphColor GlyphColor { get; init; }
+
+ public GlyphType GlyphType { get; init; }
+
+ public FontStyle FontStyle { get; init; }
- public Vector2 End { get; set; }
+ public ushort GlyphId { get; init; }
- public Pen Pen { get; set; }
+ public ushort CompositeGlyphId { get; init; }
- public float Thickness { get; internal set; }
+ public CodePoint CodePoint { get; init; }
+
+ public float PointSize { get; init; }
+
+ public float Dpi { get; init; }
+
+ public GlyphLayoutMode LayoutMode { get; init; }
+
+ public TextAttributes TextAttributes { get; init; }
+
+ public TextDecorations TextDecorations { get; init; }
+
+ public RectangleF Bounds { get; init; }
+
+ public static bool operator ==(CacheKey left, CacheKey right) => left.Equals(right);
+
+ public static bool operator !=(CacheKey left, CacheKey right) => !(left == right);
+
+ public static CacheKey FromParameters(in GlyphRendererParameters parameters, RectangleF bounds)
+ => new()
+ {
+ // Do not include the grapheme index as that will
+ // always vary per glyph instance.
+ Font = parameters.Font,
+ GlyphType = parameters.GlyphType,
+ FontStyle = parameters.FontStyle,
+ GlyphId = parameters.GlyphId,
+ CompositeGlyphId = parameters.CompositeGlyphId,
+ CodePoint = parameters.CodePoint,
+ PointSize = parameters.PointSize,
+ Dpi = parameters.Dpi,
+ LayoutMode = parameters.LayoutMode,
+ TextAttributes = parameters.TextRun.TextAttributes,
+ TextDecorations = parameters.TextRun.TextDecorations,
+ Bounds = bounds
+ };
+
+ public override bool Equals(object? obj)
+ => obj is CacheKey key && this.Equals(key);
+
+ public bool Equals(CacheKey other)
+ => this.Font == other.Font &&
+ this.GlyphColor.Equals(other.GlyphColor) &&
+ this.GlyphType == other.GlyphType &&
+ this.FontStyle == other.FontStyle &&
+ this.GlyphId == other.GlyphId &&
+ this.CompositeGlyphId == other.CompositeGlyphId &&
+ this.CodePoint.Equals(other.CodePoint) &&
+ this.PointSize == other.PointSize &&
+ this.Dpi == other.Dpi &&
+ this.LayoutMode == other.LayoutMode &&
+ this.TextAttributes == other.TextAttributes &&
+ this.TextDecorations == other.TextDecorations &&
+ this.Bounds.Equals(other.Bounds);
+
+ public override int GetHashCode()
+ {
+ HashCode hash = default;
+ hash.Add(this.Font);
+ hash.Add(this.GlyphColor);
+ hash.Add(this.GlyphType);
+ hash.Add(this.FontStyle);
+ hash.Add(this.GlyphId);
+ hash.Add(this.CompositeGlyphId);
+ hash.Add(this.CodePoint);
+ hash.Add(this.PointSize);
+ hash.Add(this.Dpi);
+ hash.Add(this.LayoutMode);
+ hash.Add(this.TextAttributes);
+ hash.Add(this.TextDecorations);
+ hash.Add(this.Bounds);
+ return hash.ToHashCode();
+ }
}
}
diff --git a/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs b/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs
index c1ed5f7f..6e13391b 100644
--- a/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs
+++ b/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs
@@ -4,18 +4,25 @@
namespace SixLabors.ImageSharp.Drawing.Processing;
///
-/// A radial gradient brush, defined by center point and radius.
+/// A radial gradient brush defined by either one circle or two circles.
+/// When one circle is provided, the gradient parameter is the distance from the center divided by the radius.
+/// When two circles are provided, the gradient parameter is computed along the family of circles interpolating
+/// between the start and end circles.
///
public sealed class RadialGradientBrush : GradientBrush
{
- private readonly PointF center;
- private readonly float radius;
+ private readonly PointF center0;
+ private readonly float radius0;
+ private readonly PointF? center1; // null means single-circle form
+ private readonly float? radius1;
- ///
- /// The center of the circular gradient and 0 for the color stops.
- /// The radius of the circular gradient and 1 for the color stops.
+ ///
+ /// Initializes a new instance of the class using a single circle.
+ ///
+ /// The center of the circular gradient.
+ /// The radius of the circular gradient.
/// Defines how the colors in the gradient are repeated.
- /// the color stops as defined in base class.
+ /// The ordered gradient stops.
public RadialGradientBrush(
PointF center,
float radius,
@@ -23,18 +30,46 @@ public RadialGradientBrush(
params ColorStop[] colorStops)
: base(repetitionMode, colorStops)
{
- this.center = center;
- this.radius = radius;
+ this.center0 = center;
+ this.radius0 = radius;
+ this.center1 = null;
+ this.radius1 = null;
+ }
+
+ ///
+ /// Initializes a new instance of the class using two circles.
+ ///
+ /// The center of the starting circle.
+ /// The radius of the starting circle.
+ /// The center of the ending circle.
+ /// The radius of the ending circle.
+ /// Defines how the colors in the gradient are repeated.
+ /// The ordered gradient stops.
+ public RadialGradientBrush(
+ PointF startCenter,
+ float startRadius,
+ PointF endCenter,
+ float endRadius,
+ GradientRepetitionMode repetitionMode,
+ params ColorStop[] colorStops)
+ : base(repetitionMode, colorStops)
+ {
+ this.center0 = startCenter;
+ this.radius0 = startRadius;
+ this.center1 = endCenter;
+ this.radius1 = endRadius;
}
///
public override bool Equals(Brush? other)
{
- if (other is RadialGradientBrush brush)
+ if (other is RadialGradientBrush b)
{
return base.Equals(other)
- && this.center.Equals(brush.center)
- && this.radius.Equals(brush.radius);
+ && this.center0.Equals(b.center0)
+ && this.radius0.Equals(b.radius0)
+ && Nullable.Equals(this.center1, b.center1)
+ && Nullable.Equals(this.radius1, b.radius1);
}
return false;
@@ -42,30 +77,48 @@ public override bool Equals(Brush? other)
///
public override int GetHashCode()
- => HashCode.Combine(base.GetHashCode(), this.center, this.radius);
+ => HashCode.Combine(base.GetHashCode(), this.center0, this.radius0, this.center1, this.radius1);
///
public override BrushApplicator CreateApplicator(
Configuration configuration,
GraphicsOptions options,
ImageFrame source,
- RectangleF region) =>
- new RadialGradientBrushApplicator(
+ RectangleF region)
+ => new RadialGradientBrushApplicator(
configuration,
options,
source,
- this.center,
- this.radius,
+ this.center0,
+ this.radius0,
+ this.center1,
+ this.radius1,
this.ColorStops,
this.RepetitionMode);
- ///
+ ///
+ /// The radial gradient brush applicator.
+ ///
private sealed class RadialGradientBrushApplicator : GradientBrushApplicator
where TPixel : unmanaged, IPixel
{
- private readonly PointF center;
+ // Single-circle fields
+ private readonly bool isTwoCircle;
+ private readonly float c0x;
+ private readonly float c0y;
+ private readonly float r0;
- private readonly float radius;
+ // Two-circle fields
+ private readonly float c1x;
+ private readonly float c1y;
+ private readonly float r1;
+
+ // Precomputed for two-circle solve
+ private readonly float dx;
+ private readonly float dy;
+ private readonly float dd; // d·d
+ private readonly float dr; // r1 - r0
+ private readonly float denom; // dd - dr^2
///
/// Initializes a new instance of the class.
@@ -73,43 +126,89 @@ private sealed class RadialGradientBrushApplicator : GradientBrushApplic
/// The configuration instance to use when performing operations.
/// The graphics options.
/// The target image.
- /// Center point of the gradient.
- /// Radius of the gradient.
+ /// Center of the starting circle.
+ /// Radius of the starting circle.
+ /// Center of the ending circle, or null to use single-circle form.
+ /// Radius of the ending circle, or null to use single-circle form.
/// Definition of colors.
/// How the colors are repeated beyond the first gradient.
public RadialGradientBrushApplicator(
Configuration configuration,
GraphicsOptions options,
ImageFrame target,
- PointF center,
- float radius,
+ PointF center0,
+ float radius0,
+ PointF? center1,
+ float? radius1,
ColorStop[] colorStops,
GradientRepetitionMode repetitionMode)
: base(configuration, options, target, colorStops, repetitionMode)
{
- this.center = center;
- this.radius = radius;
- }
+ this.c0x = center0.X;
+ this.c0y = center0.Y;
+ this.r0 = radius0;
- ///
- /// As this is a circular gradient, the position on the gradient is based on
- /// the distance of the point to the center.
- ///
- /// The x-coordinate of the target pixel.
- /// The y-coordinate of the target pixel.
- /// the position on the color gradient.
- protected override float PositionOnGradient(float x, float y)
- {
- // TODO: Can this not use Vector2 distance?
- float distance = MathF.Sqrt(MathF.Pow(this.center.X - x, 2) + MathF.Pow(this.center.Y - y, 2));
- return distance / this.radius;
+ this.isTwoCircle = center1.HasValue && radius1.HasValue;
+
+ if (this.isTwoCircle)
+ {
+ this.c1x = center1!.Value.X;
+ this.c1y = center1.Value.Y;
+ this.r1 = radius1!.Value;
+
+ this.dx = this.c1x - this.c0x;
+ this.dy = this.c1y - this.c0y;
+ this.dd = (this.dx * this.dx) + (this.dy * this.dy);
+ this.dr = this.r1 - this.r0;
+
+ // A = |d|^2 - dr^2
+ this.denom = this.dd - (this.dr * this.dr);
+ }
+ else
+ {
+ this.c1x = 0F;
+ this.c1y = 0F;
+ this.r1 = 0F;
+ this.dx = 0F;
+ this.dy = 0F;
+ this.dd = 0F;
+ this.dr = 0F;
+ this.denom = 0F;
+ }
}
///
- public override void Apply(Span scanline, int x, int y)
+ protected override float PositionOnGradient(float x, float y)
{
- // TODO: each row is symmetric across center, so we can calculate half of it and mirror it to improve performance.
- base.Apply(scanline, x, y);
+ if (!this.isTwoCircle)
+ {
+ float ux = x - this.c0x, uy = y - this.c0y;
+ return MathF.Sqrt((ux * ux) + (uy * uy)) / this.r0;
+ }
+
+ float qx = x - this.c0x, qy = y - this.c0y;
+
+ // Concentric case: centers equal -> dd == 0
+ if (this.dd == 0f)
+ {
+ // t = (|p-c0| - r0) / (r1 - r0)
+ float dist = MathF.Sqrt((qx * qx) + (qy * qy));
+ float invDr = 1f / MathF.Max(MathF.Abs(this.dr), 1e-20f);
+ return (dist - this.r0) * invDr;
+ }
+
+ // General two-circle fast form:
+ // t = ((q·d) - r0*dr) / (|d|^2 - dr^2)
+ if (this.denom == 0f)
+ {
+ // Near-singular; fall back to concentric-like ratio
+ float dist = MathF.Sqrt((qx * qx) + (qy * qy));
+ float invDr = 1f / MathF.Max(MathF.Abs(this.dr), 1e-20f);
+ return (dist - this.r0) * invDr;
+ }
+
+ float num = (qx * this.dx) + (qy * this.dy) - (this.r0 * this.dr);
+ return num / this.denom;
}
}
}
diff --git a/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs b/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs
new file mode 100644
index 00000000..5aed6678
--- /dev/null
+++ b/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs
@@ -0,0 +1,209 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Drawing.Processing;
+
+///
+/// Provides an implementation of a brush for painting sweep (conic) gradients within areas.
+/// Angles increase clockwise (y-down coordinate system) with 0° pointing to the +X direction.
+///
+public sealed class SweepGradientBrush : GradientBrush
+{
+ private readonly PointF center;
+
+ private readonly float startAngleDegrees;
+
+ private readonly float endAngleDegrees;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The center point of the sweep gradient in device space.
+ /// The starting angle, in degrees (clockwise, 0° is +X).
+ /// The ending angle, in degrees (clockwise, 0° is +X). If equal to , the gradient is treated as a full 360° sweep.
+ /// Defines how the gradient colors are repeated beyond the interval [0..1].
+ /// The gradient color stops. Ratios must be in [0..1] and are interpreted along the angular sweep.
+ public SweepGradientBrush(
+ PointF center,
+ float startAngleDegrees,
+ float endAngleDegrees,
+ GradientRepetitionMode repetitionMode,
+ params ColorStop[] colorStops)
+ : base(repetitionMode, colorStops)
+ {
+ this.center = center;
+ this.startAngleDegrees = startAngleDegrees;
+ this.endAngleDegrees = endAngleDegrees;
+ }
+
+ ///
+ public override bool Equals(Brush? other)
+ {
+ if (other is SweepGradientBrush brush)
+ {
+ return base.Equals(other)
+ && this.center.Equals(brush.center)
+ && this.startAngleDegrees.Equals(brush.startAngleDegrees)
+ && this.endAngleDegrees.Equals(brush.endAngleDegrees);
+ }
+
+ return false;
+ }
+
+ ///
+ public override int GetHashCode()
+ => HashCode.Combine(
+ base.GetHashCode(),
+ this.center,
+ this.startAngleDegrees,
+ this.endAngleDegrees);
+
+ ///
+ public override BrushApplicator CreateApplicator(
+ Configuration configuration,
+ GraphicsOptions options,
+ ImageFrame source,
+ RectangleF region)
+ => new SweepGradientBrushApplicator(
+ configuration,
+ options,
+ source,
+ this.center,
+ this.startAngleDegrees,
+ this.endAngleDegrees,
+ this.ColorStops,
+ this.RepetitionMode);
+
+ ///
+ /// The sweep (conic) gradient brush applicator.
+ ///
+ /// The pixel format.
+ private sealed class SweepGradientBrushApplicator : GradientBrushApplicator
+ where TPixel : unmanaged, IPixel
+ {
+ private const float Tau = MathF.Tau;
+
+ private readonly float cx;
+
+ private readonly float cy;
+
+ private readonly float startRad;
+
+ private readonly float invSweep;
+
+ private readonly bool isFullCircle;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configuration instance to use when performing operations.
+ /// The graphics options.
+ /// The source image.
+ /// The center of the sweep gradient.
+ /// The start angle in degrees (clockwise).
+ /// The end angle in degrees (clockwise).
+ /// The gradient color stops (ratios in [0..1]).
+ /// Defines how gradient colors are repeated outside [0..1].
+ public SweepGradientBrushApplicator(
+ Configuration configuration,
+ GraphicsOptions options,
+ ImageFrame source,
+ PointF center,
+ float startAngleDegrees,
+ float endAngleDegrees,
+ ColorStop[] colorStops,
+ GradientRepetitionMode repetitionMode)
+ : base(configuration, options, source, colorStops, repetitionMode)
+ {
+ this.cx = center.X;
+ this.cy = center.Y;
+
+ float start = GeometryUtilities.DegreeToRadian(NormalizeDegrees(startAngleDegrees));
+ float end = GeometryUtilities.DegreeToRadian(NormalizeDegrees(endAngleDegrees));
+
+ float sweep = NormalizeDeltaRadians(end - start);
+
+ // If sweep collapses numerically to ~0, treat as full circle.
+ if (MathF.Abs(sweep) < 1e-6f)
+ {
+ sweep = Tau;
+ }
+
+ this.startRad = start;
+ this.invSweep = 1f / sweep;
+ this.isFullCircle = MathF.Abs(sweep - Tau) < 1e-6f;
+ }
+
+ ///
+ /// Calculates the position parameter along the sweep gradient for the given device-space point.
+ /// The returned value is not clamped to [0..1]; repetition semantics are applied by the base class.
+ ///
+ /// The x-coordinate of the point (device space).
+ /// The y-coordinate of the point (device space).
+ /// The unbounded position on the gradient.
+ protected override float PositionOnGradient(float x, float y)
+ {
+ // Vector from center to sample point. Y is inverted to maintain clockwise angles in y-down space.
+ float dx = x - this.cx;
+ float dy = y - this.cy;
+
+ if (dx == 0f && dy == 0f)
+ {
+ // Arbitrary but stable choice for the center.
+ return 0f;
+ }
+
+ float angle = MathF.Atan2(-dy, dx); // (-π, π]
+ if (angle < 0f)
+ {
+ angle += Tau; // [0, 2π)
+ }
+
+ // Rotate basis by 180° so that 0.75 (270°) maps to "up"/top.
+ // This shifts the canonical directions: right->left, up->down, etc.
+ angle += MathF.PI;
+ if (angle >= Tau)
+ {
+ angle -= Tau;
+ }
+
+ // Phase measured clockwise from start.
+ float phase = angle - this.startRad;
+ if (phase < 0f)
+ {
+ phase += Tau;
+ }
+
+ if (this.isFullCircle)
+ {
+ // Map full circle to [0..1).
+ return phase / Tau;
+ }
+
+ // Partial sweep: phase beyond sweep -> t > 1 (lets repetition mode handle clipping).
+ return phase * this.invSweep;
+ }
+
+ private static float NormalizeDegrees(float deg)
+ {
+ float d = deg % 360f;
+ if (d < 0f)
+ {
+ d += 360f;
+ }
+
+ return d;
+ }
+
+ private static float NormalizeDeltaRadians(float delta)
+ {
+ float d = delta % Tau;
+ if (d <= 0f)
+ {
+ d += Tau;
+ }
+
+ return d;
+ }
+ }
+}
diff --git a/src/ImageSharp.Drawing/Shapes/IPathCollection.cs b/src/ImageSharp.Drawing/Shapes/IPathCollection.cs
index 346fbee6..ef283472 100644
--- a/src/ImageSharp.Drawing/Shapes/IPathCollection.cs
+++ b/src/ImageSharp.Drawing/Shapes/IPathCollection.cs
@@ -13,12 +13,12 @@ public interface IPathCollection : IEnumerable
///
/// Gets the bounds enclosing the path
///
- RectangleF Bounds { get; }
+ public RectangleF Bounds { get; }
///
/// Transforms the path using the specified matrix.
///
/// The matrix.
- /// A new path with the matrix applied to it.
- IPathCollection Transform(Matrix3x2 matrix);
+ /// A new path collection with the matrix applied to it.
+ public IPathCollection Transform(Matrix3x2 matrix);
}
diff --git a/src/ImageSharp.Drawing/Shapes/Path.cs b/src/ImageSharp.Drawing/Shapes/Path.cs
index 61d1cccf..4b992568 100644
--- a/src/ImageSharp.Drawing/Shapes/Path.cs
+++ b/src/ImageSharp.Drawing/Shapes/Path.cs
@@ -395,7 +395,7 @@ private static ReadOnlySpan FindScaler(ReadOnlySpan str, out float s
scaler = ParseFloat(str);
}
- return ReadOnlySpan.Empty;
+ return [];
}
private static bool IsSeparator(char ch)
diff --git a/src/ImageSharp.Drawing/Shapes/PathBuilder.cs b/src/ImageSharp.Drawing/Shapes/PathBuilder.cs
index 8a434681..c29510e4 100644
--- a/src/ImageSharp.Drawing/Shapes/PathBuilder.cs
+++ b/src/ImageSharp.Drawing/Shapes/PathBuilder.cs
@@ -37,6 +37,17 @@ public PathBuilder(Matrix3x2 defaultTransform)
this.ResetTransform();
}
+ ///
+ /// Gets the current transformation matrix.
+ ///
+ ///
+ /// Returns a copy of the matrix. Because is a value type,
+ /// modifications to the returned value do not affect the internal state. To change the transform,
+ /// call .
+ ///
+ /// The current transformation matrix.
+ public Matrix3x2 Transform => this.currentTransform;
+
///
/// Sets the translation to be applied to all items to follow being applied to the .
///
@@ -144,7 +155,7 @@ public PathBuilder AddLine(float x1, float y1, float x2, float y2)
public PathBuilder AddLines(IEnumerable points)
{
Guard.NotNull(points, nameof(points));
- return this.AddLines(points.ToArray());
+ return this.AddLines([.. points]);
}
///
@@ -420,7 +431,7 @@ public PathBuilder CloseAllFigures()
/// The current set of operations as a complex polygon
public IPath Build()
{
- IPath[] paths = this.figures.Where(x => !x.IsEmpty).Select(x => x.Build()).ToArray();
+ IPath[] paths = [.. this.figures.Where(x => !x.IsEmpty).Select(x => x.Build())];
if (paths.Length == 1)
{
return paths[0];
diff --git a/src/ImageSharp.Drawing/Shapes/PathExtensions.cs b/src/ImageSharp.Drawing/Shapes/PathExtensions.cs
index 7032a066..a2cffd2c 100644
--- a/src/ImageSharp.Drawing/Shapes/PathExtensions.cs
+++ b/src/ImageSharp.Drawing/Shapes/PathExtensions.cs
@@ -149,10 +149,70 @@ public static float ComputeLength(this IPath path)
if (s.IsClosed)
{
- dist += Vector2.Distance(points[0], points[points.Length - 1]);
+ dist += Vector2.Distance(points[0], points[^1]);
}
}
return dist;
}
+
+ ///
+ /// Calculates the total area of all paths in the specified collection.
+ ///
+ /// A collection of paths for which to compute the combined area. Cannot be null.
+ ///
+ /// The total area, in square units, enclosed by all paths in the collection.
+ ///
+ public static float ComputeArea(this IPathCollection paths)
+ {
+ float area = 0;
+ foreach (IPath path in paths)
+ {
+ area += path.ComputeArea();
+ }
+
+ return area;
+ }
+
+ ///
+ /// Calculates the total area enclosed by the specified path.
+ ///
+ ///
+ /// This method sums the areas of all subpaths within the path. Subpaths with fewer than three
+ /// points are ignored, as they do not form a closed region. The result is always non-negative, regardless of the
+ /// winding direction of the subpaths.
+ ///
+ ///
+ /// The path for which to compute the enclosed area. Must contain at least one subpath with three or more points to
+ /// contribute to the area calculation.
+ ///
+ ///
+ /// The total area, in square units, enclosed by all subpaths of the path. Returns 0 if the path does not contain
+ /// any subpaths with at least three points.
+ ///
+ public static float ComputeArea(this IPath path)
+ {
+ float area = 0;
+ foreach (ISimplePath s in path.Flatten())
+ {
+ ReadOnlySpan points = s.Points.Span;
+ if (points.Length < 3)
+ {
+ // Not enough points to form an area
+ continue;
+ }
+
+ float subArea = 0;
+ for (int i = 0; i < points.Length; i++)
+ {
+ PointF p1 = points[i];
+ PointF p2 = points[(i + 1) % points.Length];
+ subArea += (p1.X * p2.Y) - (p2.X * p1.Y);
+ }
+
+ area += MathF.Abs(subArea) * .5F;
+ }
+
+ return area;
+ }
}
diff --git a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs
index e6ac53ff..8cc45fbe 100644
--- a/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs
+++ b/src/ImageSharp.Drawing/Shapes/Text/BaseGlyphBuilder.cs
@@ -4,161 +4,333 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts;
+using SixLabors.Fonts.Rendering;
+using SixLabors.ImageSharp.Drawing.Processing;
namespace SixLabors.ImageSharp.Drawing.Text;
///
-/// Defines a rendering surface that Fonts can use to generate Shapes.
+/// Defines a base rendering surface that Fonts can use to generate shapes.
///
internal class BaseGlyphBuilder : IGlyphRenderer
{
- private readonly List paths = [];
private Vector2 currentPoint;
private GlyphRendererParameters parameters;
- ///
- /// Initializes a new instance of the class.
- ///
+ // Tracks whether geometry was emitted inside BeginLayer/EndLayer pairs for this glyph.
+ private bool usedLayers;
+
+ // Tracks whether we are currently inside a layer block.
+ private bool inLayer;
+
+ // Per-GRAPHEME layered capture (aggregate multiple glyphs of the same grapheme, e.g. COLR v0 layers):
+ private GlyphPathCollection.Builder? graphemeBuilder;
+ private int graphemePathCount;
+ private int currentGraphemeIndex = -1;
+ private readonly List currentGlyphs = [];
+ private TextDecorationDetails? previousUnderlineTextDecoration;
+ private TextDecorationDetails? previousOverlineTextDecoration;
+ private TextDecorationDetails? previousStrikeoutTextDecoration;
+
+ // Per-layer (within current grapheme) bookkeeping:
+ private int layerStartIndex;
+ private Paint? currentLayerPaint;
+ private FillRule currentLayerFillRule;
+ private ClipQuad? currentClipBounds;
+
public BaseGlyphBuilder() => this.Builder = new PathBuilder();
- ///
- /// Initializes a new instance of the class.
- ///
- /// The default transform.
public BaseGlyphBuilder(Matrix3x2 transform) => this.Builder = new PathBuilder(transform);
- protected List PathList { get; } = new();
-
///
- /// Gets the paths that have been rendered by the current instance.
+ /// Gets the flattened paths captured for all glyphs/graphemes.
///
- public IPathCollection Paths => new PathCollection(this.PathList.ToArray());
+ public IPathCollection Paths => new PathCollection(this.CurrentPaths);
///
- /// Gets the path builder for the current instance.
+ /// Gets the layer-preserving collections captured per grapheme in rendering order.
+ /// Each entry aggregates all glyph layers that belong to a single grapheme cluster.
///
+ public IReadOnlyList Glyphs => this.currentGlyphs;
+
protected PathBuilder Builder { get; }
- ///
- void IGlyphRenderer.EndText() => this.EndText();
+ ///
+ /// Gets the paths captured for the current glyph/grapheme.
+ ///
+ protected List CurrentPaths { get; } = [];
+
+ void IGlyphRenderer.EndText()
+ {
+ // Finalize the last grapheme, if any:
+ if (this.graphemeBuilder is not null && this.graphemePathCount > 0)
+ {
+ this.currentGlyphs.Add(this.graphemeBuilder.Build());
+ }
+
+ this.graphemeBuilder = null;
+ this.graphemePathCount = 0;
+ this.currentGraphemeIndex = -1;
+ this.previousUnderlineTextDecoration = null;
+ this.previousOverlineTextDecoration = null;
+ this.previousStrikeoutTextDecoration = null;
+
+ this.EndText();
+ }
- ///
void IGlyphRenderer.BeginText(in FontRectangle bounds) => this.BeginText(bounds);
- ///
bool IGlyphRenderer.BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
{
+ // If grapheme changed, flush previous aggregate and start a new one:
+ if (this.graphemeBuilder is not null && this.currentGraphemeIndex != parameters.GraphemeIndex)
+ {
+ if (this.graphemePathCount > 0)
+ {
+ this.currentGlyphs.Add(this.graphemeBuilder.Build());
+ }
+
+ this.graphemeBuilder = null;
+ this.graphemePathCount = 0;
+ }
+
+ if (this.graphemeBuilder is null)
+ {
+ this.graphemeBuilder = new GlyphPathCollection.Builder();
+ this.currentGraphemeIndex = parameters.GraphemeIndex;
+ this.graphemePathCount = 0;
+ }
+
this.parameters = parameters;
this.Builder.Clear();
+ this.usedLayers = false;
+ this.inLayer = false;
+
+ this.layerStartIndex = this.graphemePathCount;
+ this.currentLayerPaint = null;
+ this.currentLayerFillRule = FillRule.NonZero;
+ this.currentClipBounds = null;
this.BeginGlyph(in bounds, in parameters);
return true;
}
- ///
- /// Begins the figure.
- ///
+ ///
void IGlyphRenderer.BeginFigure() => this.Builder.StartFigure();
- ///
- /// Draws a cubic bezier from the current point to the
- ///
- /// The second control point.
- /// The third control point.
- /// The point.
+ ///
void IGlyphRenderer.CubicBezierTo(Vector2 secondControlPoint, Vector2 thirdControlPoint, Vector2 point)
{
this.Builder.AddCubicBezier(this.currentPoint, secondControlPoint, thirdControlPoint, point);
this.currentPoint = point;
}
- ///
- /// Ends the glyph.
- ///
+ ///
void IGlyphRenderer.EndGlyph()
{
- this.PathList.Add(this.Builder.Build());
+ // If the glyph did not open any explicit layer, treat its geometry as a single layer in the current grapheme:
+ if (!this.usedLayers)
+ {
+ IPath path = this.Builder.Build();
+
+ this.CurrentPaths.Add(path);
+
+ if (this.graphemeBuilder is not null)
+ {
+ this.graphemeBuilder.AddPath(path);
+ this.graphemeBuilder.AddLayer(
+ startIndex: this.graphemePathCount,
+ count: 1,
+ paint: null,
+ fillRule: FillRule.NonZero,
+ bounds: path.Bounds,
+ kind: GlyphLayerKind.Glyph);
+
+ this.graphemePathCount++;
+ }
+ }
+
this.EndGlyph();
+ this.Builder.Clear();
+ this.inLayer = false;
+ this.usedLayers = false;
+ this.layerStartIndex = this.graphemePathCount;
}
- ///
- /// Ends the figure.
- ///
+ ///
void IGlyphRenderer.EndFigure() => this.Builder.CloseFigure();
- ///
- /// Draws a line from the current point to the .
- ///
- /// The point.
+ ///
void IGlyphRenderer.LineTo(Vector2 point)
{
this.Builder.AddLine(this.currentPoint, point);
this.currentPoint = point;
}
- ///
- /// Moves to current point to the supplied vector.
- ///
- /// The point.
+ ///
void IGlyphRenderer.MoveTo(Vector2 point)
{
this.Builder.StartFigure();
this.currentPoint = point;
}
- ///
- /// Draws a quadratics bezier from the current point to the
- ///
- /// The second control point.
- /// The point.
- void IGlyphRenderer.QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point)
+ ///
+ void IGlyphRenderer.ArcTo(float radiusX, float radiusY, float rotation, bool largeArc, bool sweep, Vector2 point)
{
- this.Builder.AddQuadraticBezier(this.currentPoint, secondControlPoint, point);
+ this.Builder.AddArc(this.currentPoint, radiusX, radiusY, rotation, largeArc, sweep, point);
this.currentPoint = point;
}
- /// Called before any glyphs have been rendered.
- /// The bounds the text will be rendered at and at what size.
- protected virtual void BeginText(in FontRectangle bounds)
+ ///
+ void IGlyphRenderer.QuadraticBezierTo(Vector2 secondControlPoint, Vector2 point)
{
+ this.Builder.AddQuadraticBezier(this.currentPoint, secondControlPoint, point);
+ this.currentPoint = point;
}
- ///
- protected virtual void BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
+ ///
+ void IGlyphRenderer.BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds)
{
- }
+ this.usedLayers = true;
+ this.inLayer = true;
+ this.layerStartIndex = this.graphemePathCount;
+ this.currentLayerPaint = paint;
+ this.currentLayerFillRule = fillRule;
+ this.currentClipBounds = clipBounds;
- ///
- protected virtual void EndGlyph()
- {
+ this.Builder.Clear();
+ this.BeginLayer(paint, fillRule, clipBounds);
}
- ///
- protected virtual void EndText()
+ ///
+ void IGlyphRenderer.EndLayer()
{
- }
+ if (!this.inLayer)
+ {
+ return;
+ }
- public virtual TextDecorations EnabledDecorations()
- => this.parameters.TextRun.TextDecorations;
+ IPath path = this.Builder.Build();
- public virtual void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
+ if (this.currentClipBounds is not null)
+ {
+ ClipQuad clip = this.currentClipBounds.Value;
+ PointF[] points = [clip.TopLeft, clip.TopRight, clip.BottomRight, clip.BottomLeft];
+ LinearLineSegment segment = new(points);
+ Polygon polygon = new(segment);
+
+ ShapeOptions options = new()
+ {
+ ClippingOperation = ClippingOperation.Intersection,
+ IntersectionRule = TextUtilities.MapFillRule(this.currentLayerFillRule)
+ };
+
+ path = path.Clip(options, polygon);
+ }
+
+ this.CurrentPaths.Add(path);
+
+ if (this.graphemeBuilder is not null)
+ {
+ this.graphemeBuilder.AddPath(path);
+ this.graphemeBuilder.AddLayer(
+ startIndex: this.layerStartIndex,
+ count: 1,
+ paint: this.currentLayerPaint,
+ fillRule: this.currentLayerFillRule,
+ bounds: path.Bounds,
+ kind: GlyphLayerKind.Painted);
+
+ this.graphemePathCount++;
+ }
+
+ this.Builder.Clear();
+ this.inLayer = false;
+ this.currentLayerPaint = null;
+ this.currentLayerFillRule = FillRule.NonZero;
+ this.currentClipBounds = null;
+ this.EndLayer();
+ }
+
+ ///
+ void IGlyphRenderer.SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
{
if (thickness == 0)
{
return;
}
+ // Clamp the thickness to whole pixels.
thickness = MathF.Max(1F, (float)Math.Round(thickness));
- IGlyphRenderer renderer = (IGlyphRenderer)this;
+ IGlyphRenderer renderer = this;
- // Expand the points to create a rectangle centered around the line.
bool rotated = this.parameters.LayoutMode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated;
Vector2 pad = rotated ? new Vector2(thickness * .5F, 0) : new Vector2(0, thickness * .5F);
- // Clamp the line to the pixel grid.
start = ClampToPixel(start, (int)thickness, rotated);
end = ClampToPixel(end, (int)thickness, rotated);
- // Offset to create the rectangle.
+ // Sometimes the start and end points do not align properly leaving pixel sized gaps
+ // so we need to adjust them. Use any previous decoration to try and continue the line.
+ TextDecorationDetails? previous = textDecorations switch
+ {
+ TextDecorations.Underline => this.previousUnderlineTextDecoration,
+ TextDecorations.Overline => this.previousOverlineTextDecoration,
+ TextDecorations.Strikeout => this.previousStrikeoutTextDecoration,
+ _ => null
+ };
+
+ if (previous != null)
+ {
+ float prevThickness = previous.Value.Thickness;
+ Vector2 prevStart = previous.Value.Start;
+ Vector2 prevEnd = previous.Value.End;
+
+ // If the previous line is identical to the new one ignore it.
+ // This can happen when multiple glyph layers are used.
+ if (prevStart == start && prevEnd == end)
+ {
+ return;
+ }
+
+ // Align the new line with the previous one if they are close enough.
+ // Use a 2 pixel threshold to account for anti-aliasing gaps.
+ if (rotated)
+ {
+ if (thickness == prevThickness
+ && prevEnd.Y + 2 >= start.Y
+ && prevEnd.X == start.X)
+ {
+ start = prevEnd;
+ }
+ }
+ else if (thickness == prevThickness
+ && prevEnd.Y == start.Y
+ && prevEnd.X + 2 >= start.X)
+ {
+ start = prevEnd;
+ }
+ }
+
+ TextDecorationDetails current = new()
+ {
+ Start = start,
+ End = end,
+ Thickness = thickness
+ };
+
+ switch (textDecorations)
+ {
+ case TextDecorations.Underline:
+ this.previousUnderlineTextDecoration = current;
+ break;
+ case TextDecorations.Strikeout:
+ this.previousStrikeoutTextDecoration = current;
+ break;
+ case TextDecorations.Overline:
+ this.previousOverlineTextDecoration = current;
+ break;
+ }
+
Vector2 a = start - pad;
Vector2 b = start + pad;
Vector2 c = end + pad;
@@ -177,14 +349,80 @@ public virtual void SetDecoration(TextDecorations textDecorations, Vector2 start
offset = rotated ? new Vector2(-(thickness * .5F), 0) : new Vector2(0, thickness * .5F);
}
+ // We clamp the start and end points to the pixel grid to avoid anti-aliasing
+ // when there is no transform.
renderer.BeginFigure();
-
- // Now draw the rectangle clamped to the pixel grid.
renderer.MoveTo(ClampToPixel(a + offset));
renderer.LineTo(ClampToPixel(b + offset));
renderer.LineTo(ClampToPixel(c + offset));
renderer.LineTo(ClampToPixel(d + offset));
renderer.EndFigure();
+
+ IPath path = this.Builder.Build();
+
+ // If the path is degenerate (e.g. zero width line) we just skip it
+ // and return. This might happen when clamping moves the points.
+ if (path.Bounds.IsEmpty)
+ {
+ this.Builder.Clear();
+ return;
+ }
+
+ this.CurrentPaths.Add(path);
+ if (this.graphemeBuilder is not null)
+ {
+ this.graphemeBuilder.AddPath(path);
+ this.graphemeBuilder.AddLayer(
+ startIndex: this.layerStartIndex,
+ count: 1,
+ paint: this.currentLayerPaint,
+ fillRule: FillRule.NonZero,
+ bounds: path.Bounds,
+ kind: GlyphLayerKind.Decoration);
+
+ this.graphemePathCount++;
+ }
+
+ this.Builder.Clear();
+ this.SetDecoration(textDecorations, start, end, thickness);
+ }
+
+ ///
+ protected virtual void BeginText(in FontRectangle bounds)
+ {
+ }
+
+ ///
+ protected virtual void BeginGlyph(in FontRectangle bounds, in GlyphRendererParameters parameters)
+ {
+ }
+
+ ///
+ protected virtual void EndGlyph()
+ {
+ }
+
+ ///
+ protected virtual void EndText()
+ {
+ }
+
+ ///
+ protected virtual void BeginLayer(Paint? paint, FillRule fillRule, ClipQuad? clipBounds)
+ {
+ }
+
+ ///
+ protected virtual void EndLayer()
+ {
+ }
+
+ public virtual TextDecorations EnabledDecorations()
+ => this.parameters.TextRun.TextDecorations;
+
+ ///
+ public virtual void SetDecoration(TextDecorations textDecorations, Vector2 start, Vector2 end, float thickness)
+ {
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -207,4 +445,13 @@ private static PointF ClampToPixel(PointF point, int thickness, bool rotated)
return Point.Truncate(point) + new Vector2(0, .5F);
}
+
+ private struct TextDecorationDetails
+ {
+ public Vector2 Start { get; set; }
+
+ public Vector2 End { get; set; }
+
+ public float Thickness { get; internal set; }
+ }
}
diff --git a/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerInfo.cs b/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerInfo.cs
new file mode 100644
index 00000000..da4a0d4f
--- /dev/null
+++ b/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerInfo.cs
@@ -0,0 +1,113 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.ImageSharp.Drawing.Text;
+
+///
+/// Describes a single painted layer as a span within the glyph's path list.
+///
+public readonly struct GlyphLayerInfo
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// Start index (inclusive) of the layer's paths within the glyph's path list.
+ /// Number of paths in this layer.
+ /// The layer paint (null means use renderer default).
+ /// The fill rule to use for this layer.
+ /// Axis-aligned bounds of the layer geometry.
+ /// An optional semantic hint for the layer type.
+ internal GlyphLayerInfo(
+ int startIndex,
+ int count,
+ Paint? paint,
+ FillRule fillRule,
+ RectangleF bounds,
+ GlyphLayerKind kind)
+ {
+ this.StartIndex = startIndex;
+ this.Count = count;
+ this.Paint = paint;
+ this.IntersectionRule = TextUtilities.MapFillRule(fillRule);
+
+ CompositeMode compositeMode = paint?.CompositeMode ?? CompositeMode.SrcOver;
+ this.PixelAlphaCompositionMode = TextUtilities.MapCompositionMode(compositeMode);
+ this.PixelColorBlendingMode = TextUtilities.MapBlendingMode(compositeMode);
+ this.Bounds = bounds;
+ this.Kind = kind;
+ }
+
+ private GlyphLayerInfo(
+ int startIndex,
+ int count,
+ Paint? paint,
+ IntersectionRule intersectionRule,
+ PixelAlphaCompositionMode compositionMode,
+ PixelColorBlendingMode colorBlendingMode,
+ RectangleF bounds,
+ GlyphLayerKind kind)
+ {
+ this.StartIndex = startIndex;
+ this.Count = count;
+ this.Paint = paint;
+ this.IntersectionRule = intersectionRule;
+ this.PixelAlphaCompositionMode = compositionMode;
+ this.PixelColorBlendingMode = colorBlendingMode;
+ this.Bounds = bounds;
+ this.Kind = kind;
+ }
+
+ ///
+ /// Gets the start index (inclusive) of the layer span within the glyph's path list.
+ ///
+ public int StartIndex { get; }
+
+ ///
+ /// Gets the number of paths in this layer.
+ ///
+ public int Count { get; }
+
+ ///
+ /// Gets the paint definition to use for this layer; may be .
+ ///
+ public Paint? Paint { get; }
+
+ ///
+ /// Gets the fill rule for rasterization of this layer.
+ ///
+ public IntersectionRule IntersectionRule { get; }
+
+ ///
+ /// Gets the pixel alpha composition mode to use for this layer.
+ ///
+ public PixelAlphaCompositionMode PixelAlphaCompositionMode { get; }
+
+ ///
+ /// Gets the pixel color blending mode to use for this layer.
+ ///
+ public PixelColorBlendingMode PixelColorBlendingMode { get; }
+
+ ///
+ /// Gets the bounds of the layer geometry (device space).
+ ///
+ public RectangleF Bounds { get; }
+
+ ///
+ /// Gets the semantic kind of the layer (for policy decisions).
+ ///
+ public GlyphLayerKind Kind { get; }
+
+ internal static GlyphLayerInfo Transform(in GlyphLayerInfo info, Matrix3x2 matrix)
+ => new(
+ info.StartIndex,
+ info.Count,
+ info.Paint,
+ info.IntersectionRule,
+ info.PixelAlphaCompositionMode,
+ info.PixelColorBlendingMode,
+ RectangleF.Transform(info.Bounds, matrix),
+ info.Kind);
+}
diff --git a/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerKind.cs b/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerKind.cs
new file mode 100644
index 00000000..ec1f0f18
--- /dev/null
+++ b/src/ImageSharp.Drawing/Shapes/Text/GlyphLayerKind.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Drawing.Text;
+
+///
+/// Optional semantic classification for layers to aid monochrome projection or decoration handling.
+///
+public enum GlyphLayerKind
+{
+ ///
+ /// Regular glyph geometry layer.
+ ///
+ Glyph = 0,
+
+ ///
+ /// Text decoration geometry (underline/overline/strikethrough).
+ ///
+ Decoration = 1,
+
+ ///
+ /// Painted layer (e.g. color emoji glyph).
+ ///
+ Painted = 2
+}
diff --git a/src/ImageSharp.Drawing/Shapes/Text/GlyphPathCollection.cs b/src/ImageSharp.Drawing/Shapes/Text/GlyphPathCollection.cs
new file mode 100644
index 00000000..46d08a9f
--- /dev/null
+++ b/src/ImageSharp.Drawing/Shapes/Text/GlyphPathCollection.cs
@@ -0,0 +1,184 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Collections.ObjectModel;
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.ImageSharp.Drawing.Text;
+
+///
+/// A geometry + paint container for a single glyph, preserving painted layer boundaries.
+///
+public sealed class GlyphPathCollection
+{
+ private readonly List paths;
+ private readonly ReadOnlyCollection readOnlyPaths;
+ private readonly List layers;
+ private readonly ReadOnlyCollection readOnlyLayers;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// All paths emitted for the glyph in z-order.
+ /// Layer descriptors referring to spans within .
+ internal GlyphPathCollection(List paths, List layers)
+ {
+ Guard.NotNull(paths, nameof(paths));
+ Guard.NotNull(layers, nameof(layers));
+
+ this.paths = paths;
+ this.layers = layers;
+
+ this.readOnlyPaths = new ReadOnlyCollection(this.paths);
+ this.readOnlyLayers = new ReadOnlyCollection(this.layers);
+ this.Paths = new PathCollection(this.paths);
+ }
+
+ ///
+ /// Gets the flattened geometry for the glyph (all paths in z-order).
+ /// This is equivalent to concatenating all layer spans.
+ ///
+ public IPathCollection Paths { get; }
+
+ ///
+ /// Gets a read-only view of all individual paths in z-order.
+ ///
+ public IReadOnlyList PathList => this.readOnlyPaths;
+
+ ///
+ /// Gets a read-only list of layer descriptors preserving paint, fill rule and path spans.
+ ///
+ public IReadOnlyList Layers => this.readOnlyLayers;
+
+ ///
+ /// Gets the number of layers.
+ ///
+ public int LayerCount => this.layers.Count;
+
+ ///
+ /// Gets an axis-aligned bounding box of the entire glyph in device space.
+ ///
+ public RectangleF Bounds => this.Paths.Bounds;
+
+ ///
+ /// Transforms the glyph using the specified matrix.
+ ///
+ /// The transform matrix.
+ ///
+ /// A new with the matrix applied to it.
+ ///
+ public GlyphPathCollection Transform(Matrix3x2 matrix)
+ {
+ List transformed = new(this.paths.Count);
+
+ for (int i = 0; i < this.paths.Count; i++)
+ {
+ transformed.Add(this.paths[i].Transform(matrix));
+ }
+
+ List transformedLayers = new(this.layers.Count);
+ for (int i = 0; i < this.layers.Count; i++)
+ {
+ transformedLayers.Add(GlyphLayerInfo.Transform(this.layers[i], matrix));
+ }
+
+ return new GlyphPathCollection(transformed, transformedLayers);
+ }
+
+ ///
+ /// Creates a containing only the paths from layers that
+ /// satisfy . Useful to project to monochrome.
+ ///
+ /// A filter deciding whether to keep a layer.
+ /// A new with the selected paths.
+ public PathCollection ToPathCollection(Func? predicate = null)
+ {
+ List kept = [];
+ for (int i = 0; i < this.layers.Count; i++)
+ {
+ GlyphLayerInfo li = this.layers[i];
+ if (predicate?.Invoke(li) == false)
+ {
+ continue;
+ }
+
+ int end = li.StartIndex + li.Count;
+ for (int p = li.StartIndex; p < end; p++)
+ {
+ kept.Add(this.paths[p]);
+ }
+ }
+
+ return new PathCollection(kept);
+ }
+
+ ///
+ /// Gets a view of a single layer's geometry.
+ ///
+ /// The zero-based layer index.
+ /// A path collection comprising only that layer's span.
+ public PathCollection GetLayerPaths(int layerIndex)
+ {
+ Guard.MustBeLessThan(layerIndex, this.layers.Count, nameof(layerIndex));
+
+ GlyphLayerInfo li = this.layers[layerIndex];
+ List chunk = new(li.Count);
+ int end = li.StartIndex + li.Count;
+ for (int p = li.StartIndex; p < end; p++)
+ {
+ chunk.Add(this.paths[p]);
+ }
+
+ return new PathCollection(chunk);
+ }
+
+ ///
+ /// Builder used by glyph renderers to populate a .
+ ///
+ internal sealed class Builder
+ {
+ private readonly List paths = [];
+ private readonly List layers = [];
+
+ ///
+ /// Adds a completed path to the collection (current z-order position).
+ ///
+ /// The path to add.
+ public void AddPath(IPath path) => this.paths.Add(path);
+
+ ///
+ /// Adds a layer descriptor pointing at the most recently added paths.
+ ///
+ /// Start index within the path list (inclusive).
+ /// Number of paths belonging to this layer.
+ /// The paint for this layer (may be null for default).
+ /// The fill rule for this layer.
+ /// Optional cached bounds for this layer.
+ /// Optional semantic kind (eg. Decoration).
+ ///
+ /// Thrown if the specified span is out of range of the current path list.
+ ///
+ public void AddLayer(
+ int startIndex,
+ int count,
+ Paint? paint,
+ FillRule fillRule,
+ RectangleF bounds,
+ GlyphLayerKind kind = GlyphLayerKind.Glyph)
+ {
+ if (startIndex < 0 || count < 0 || startIndex + count > this.paths.Count)
+ {
+ throw new ArgumentOutOfRangeException(nameof(count), "Layer span is out of range of the current path list.");
+ }
+
+ this.layers.Add(new GlyphLayerInfo(startIndex, count, paint, fillRule, bounds, kind));
+ }
+
+ ///
+ /// Builds the immutable .
+ ///
+ /// The collection.
+ public GlyphPathCollection Build() => new(this.paths, this.layers);
+ }
+}
diff --git a/src/ImageSharp.Drawing/Shapes/Text/PathGlyphBuilder.cs b/src/ImageSharp.Drawing/Shapes/Text/PathGlyphBuilder.cs
index 877daae6..706b772a 100644
--- a/src/ImageSharp.Drawing/Shapes/Text/PathGlyphBuilder.cs
+++ b/src/ImageSharp.Drawing/Shapes/Text/PathGlyphBuilder.cs
@@ -4,6 +4,7 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.Fonts;
+using SixLabors.Fonts.Rendering;
namespace SixLabors.ImageSharp.Drawing.Text;
diff --git a/src/ImageSharp.Drawing/Shapes/Text/TextBuilder.cs b/src/ImageSharp.Drawing/Shapes/Text/TextBuilder.cs
index 1df62e59..4597384b 100644
--- a/src/ImageSharp.Drawing/Shapes/Text/TextBuilder.cs
+++ b/src/ImageSharp.Drawing/Shapes/Text/TextBuilder.cs
@@ -3,22 +3,23 @@
using System.Numerics;
using SixLabors.Fonts;
-using SixLabors.ImageSharp.Drawing.Text;
+using SixLabors.Fonts.Rendering;
-namespace SixLabors.ImageSharp.Drawing;
+namespace SixLabors.ImageSharp.Drawing.Text;
///
-/// Provides mechanisms for building instances from text strings.
+/// Builds vector shapes from text using the provided layout and rendering options.
///
public static class TextBuilder
{
///
- /// Generates the shapes corresponding the glyphs described by the text options.
+ /// Generates the combined outline paths for all rendered glyphs in .
+ /// The result merges per-glyph outlines into a single suitable for filling or stroking as one unit.
///
- /// The text to generate glyphs for.
- /// The text rendering options.
- /// The
- public static IPathCollection GenerateGlyphs(string text, TextOptions textOptions)
+ /// The text to shape and render.
+ /// The text rendering and layout options.
+ /// The combined for the rendered glyphs.
+ public static IPathCollection GeneratePaths(string text, TextOptions textOptions)
{
GlyphBuilder glyphBuilder = new();
TextRenderer renderer = new(glyphBuilder);
@@ -29,13 +30,32 @@ public static IPathCollection GenerateGlyphs(string text, TextOptions textOption
}
///
- /// Generates the shapes corresponding the glyphs described by the text options along the described path.
+ /// Generates per-glyph path data and metadata for the rendered .
+ /// Each entry contains the combined outline paths for a glyph and associated metadata that enables intelligent fill or stroke decisions at the glyph level.
///
- /// The text to generate glyphs for
- /// The path to draw the text in relation to
- /// The text rendering options.
- /// The
- public static IPathCollection GenerateGlyphs(string text, IPath path, TextOptions textOptions)
+ /// The text to shape and render.
+ /// The text rendering and layout options.
+ /// A read-only list of entries, one for each rendered glyph.
+ public static IReadOnlyList GenerateGlyphs(string text, TextOptions textOptions)
+ {
+ GlyphBuilder glyphBuilder = new();
+ TextRenderer renderer = new(glyphBuilder);
+
+ renderer.RenderText(text, textOptions);
+
+ return glyphBuilder.Glyphs;
+ }
+
+ ///
+ /// Generates the combined outline paths for all rendered glyphs in ,
+ /// laid out along the supplied baseline.
+ /// The result merges per-glyph outlines into a single .
+ ///
+ /// The text to shape and render.
+ /// The path that defines the text baseline.
+ /// The text rendering and layout options.
+ /// The combined for the rendered glyphs.
+ public static IPathCollection GeneratePaths(string text, IPath path, TextOptions textOptions)
{
(IPath Path, TextOptions TextOptions) transformed = ConfigureOptions(textOptions, path);
PathGlyphBuilder glyphBuilder = new(transformed.Path);
@@ -46,6 +66,26 @@ public static IPathCollection GenerateGlyphs(string text, IPath path, TextOption
return glyphBuilder.Paths;
}
+ ///
+ /// Generates per-glyph path data and metadata for the rendered ,
+ /// laid out along the supplied baseline.
+ /// Each entry contains the combined outline paths for a glyph and associated metadata.
+ ///
+ /// The text to shape and render.
+ /// The path that defines the text baseline.
+ /// The text rendering and layout options.
+ /// A read-only list of entries, one for each rendered glyph.
+ public static IReadOnlyList GenerateGlyphs(string text, IPath path, TextOptions textOptions)
+ {
+ (IPath Path, TextOptions TextOptions) transformed = ConfigureOptions(textOptions, path);
+ PathGlyphBuilder glyphBuilder = new(transformed.Path);
+ TextRenderer renderer = new(glyphBuilder);
+
+ renderer.RenderText(text, transformed.TextOptions);
+
+ return glyphBuilder.Glyphs;
+ }
+
private static (IPath Path, TextOptions TextOptions) ConfigureOptions(TextOptions options, IPath path)
{
// When a path is specified we should explicitly follow that path
diff --git a/src/ImageSharp.Drawing/Shapes/Text/TextUtilities.cs b/src/ImageSharp.Drawing/Shapes/Text/TextUtilities.cs
new file mode 100644
index 00000000..1c59fcf8
--- /dev/null
+++ b/src/ImageSharp.Drawing/Shapes/Text/TextUtilities.cs
@@ -0,0 +1,94 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Rendering;
+using SixLabors.ImageSharp.Drawing.Processing;
+
+namespace SixLabors.ImageSharp.Drawing.Text;
+
+internal static class TextUtilities
+{
+ public static IntersectionRule MapFillRule(FillRule fillRule)
+ => fillRule switch
+ {
+ FillRule.EvenOdd => IntersectionRule.EvenOdd,
+ FillRule.NonZero => IntersectionRule.NonZero,
+ _ => IntersectionRule.NonZero,
+ };
+
+ public static PixelAlphaCompositionMode MapCompositionMode(CompositeMode mode)
+ => mode switch
+ {
+ CompositeMode.Clear => PixelAlphaCompositionMode.Clear,
+ CompositeMode.Src => PixelAlphaCompositionMode.Src,
+ CompositeMode.Dest => PixelAlphaCompositionMode.Dest,
+ CompositeMode.SrcOver => PixelAlphaCompositionMode.SrcOver,
+ CompositeMode.DestOver => PixelAlphaCompositionMode.DestOver,
+ CompositeMode.SrcIn => PixelAlphaCompositionMode.SrcIn,
+ CompositeMode.DestIn => PixelAlphaCompositionMode.DestIn,
+ CompositeMode.SrcOut => PixelAlphaCompositionMode.SrcOut,
+ CompositeMode.DestOut => PixelAlphaCompositionMode.DestOut,
+ CompositeMode.SrcAtop => PixelAlphaCompositionMode.SrcAtop,
+ CompositeMode.DestAtop => PixelAlphaCompositionMode.DestAtop,
+ CompositeMode.Xor => PixelAlphaCompositionMode.Xor,
+ _ => PixelAlphaCompositionMode.SrcOver,
+ };
+
+ public static PixelColorBlendingMode MapBlendingMode(CompositeMode mode)
+ => mode switch
+ {
+ CompositeMode.Plus => PixelColorBlendingMode.Add,
+ CompositeMode.Screen => PixelColorBlendingMode.Screen,
+ CompositeMode.Overlay => PixelColorBlendingMode.Overlay,
+ CompositeMode.Darken => PixelColorBlendingMode.Darken,
+ CompositeMode.Lighten => PixelColorBlendingMode.Lighten,
+ CompositeMode.HardLight => PixelColorBlendingMode.HardLight,
+ CompositeMode.Multiply => PixelColorBlendingMode.Multiply,
+
+ // TODO: We do not support the following separate alpha blending modes:
+ // - ColorDodge, ColorBurn, SoftLight, Difference, Exclusion
+ // TODO: We do not support the non-alpha blending modes.
+ // - Hue, Saturation, Color, Luminosity
+ _ => PixelColorBlendingMode.Normal
+ };
+
+ public static DrawingOptions CloneOrReturnForRules(
+ this DrawingOptions drawingOptions,
+ IntersectionRule intersectionRule,
+ PixelAlphaCompositionMode compositionMode,
+ PixelColorBlendingMode colorBlendingMode)
+ {
+ if (drawingOptions.ShapeOptions.IntersectionRule == intersectionRule &&
+ drawingOptions.GraphicsOptions.AlphaCompositionMode == compositionMode &&
+ drawingOptions.GraphicsOptions.ColorBlendingMode == colorBlendingMode)
+ {
+ return drawingOptions;
+ }
+
+ ShapeOptions shapeOptions = drawingOptions.ShapeOptions.DeepClone();
+ shapeOptions.IntersectionRule = intersectionRule;
+
+ GraphicsOptions graphicsOptions = drawingOptions.GraphicsOptions.DeepClone();
+ graphicsOptions.AlphaCompositionMode = compositionMode;
+ graphicsOptions.ColorBlendingMode = colorBlendingMode;
+
+ return new DrawingOptions(graphicsOptions, shapeOptions, drawingOptions.Transform);
+ }
+
+ public static GraphicsOptions CloneOrReturnForRules(
+ this GraphicsOptions graphicsOptions,
+ PixelAlphaCompositionMode compositionMode,
+ PixelColorBlendingMode colorBlendingMode)
+ {
+ if (graphicsOptions.AlphaCompositionMode == compositionMode &&
+ graphicsOptions.ColorBlendingMode == colorBlendingMode)
+ {
+ return graphicsOptions;
+ }
+
+ GraphicsOptions clone = graphicsOptions.DeepClone();
+ clone.AlphaCompositionMode = compositionMode;
+ clone.ColorBlendingMode = colorBlendingMode;
+ return clone;
+ }
+}
diff --git a/tests/ImageSharp.Drawing.Benchmarks/Drawing/DrawTextOutline.cs b/tests/ImageSharp.Drawing.Benchmarks/Drawing/DrawTextOutline.cs
index 29f47d7e..245d7e85 100644
--- a/tests/ImageSharp.Drawing.Benchmarks/Drawing/DrawTextOutline.cs
+++ b/tests/ImageSharp.Drawing.Benchmarks/Drawing/DrawTextOutline.cs
@@ -6,6 +6,7 @@
using BenchmarkDotNet.Attributes;
using SixLabors.Fonts;
using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Drawing.Text;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Brush = SixLabors.ImageSharp.Drawing.Processing.Brush;
@@ -95,7 +96,7 @@ static IImageProcessingContext DrawTextOldVersion(
Brush brush,
Pen pen)
{
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, textOptions);
DrawingOptions pathOptions = new() { GraphicsOptions = options.GraphicsOptions };
if (brush != null)
diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/FillLinearGradientBrushTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/FillLinearGradientBrushTests.cs
index fcac011c..9b823400 100644
--- a/tests/ImageSharp.Drawing.Tests/Drawing/FillLinearGradientBrushTests.cs
+++ b/tests/ImageSharp.Drawing.Tests/Drawing/FillLinearGradientBrushTests.cs
@@ -437,4 +437,27 @@ public void BrushApplicatorIsThreadSafeIssue1044(TestImageProvider(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ => provider.VerifyOperation(
+ image =>
+ {
+ Color red = Color.Red;
+ Color yellow = Color.Yellow;
+
+ // Start -> End along TL->BR, rotated to horizontal via p2
+ LinearGradientBrush brush = new(
+ new Point(0, 0),
+ new Point(200, 200),
+ new Point(0, 100), // p2 picks horizontal axis
+ GradientRepetitionMode.None,
+ new ColorStop(0, red),
+ new ColorStop(1, yellow));
+ image.Mutate(x => x.Fill(brush));
+ },
+ false,
+ false);
}
diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/FillSweepGradientBrushTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/FillSweepGradientBrushTests.cs
new file mode 100644
index 00000000..cc4518e6
--- /dev/null
+++ b/tests/ImageSharp.Drawing.Tests/Drawing/FillSweepGradientBrushTests.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+
+namespace SixLabors.ImageSharp.Drawing.Tests.Drawing;
+
+[GroupOutput("Drawing/GradientBrushes")]
+public class FillSweepGradientBrushTests
+{
+ private static readonly ImageComparer TolerantComparer = ImageComparer.TolerantPercentage(0.01f);
+
+ [Theory]
+ [WithBlankImage(200, 200, PixelTypes.Rgba32, 0f, 360f)]
+ [WithBlankImage(200, 200, PixelTypes.Rgba32, 90f, 450f)]
+ [WithBlankImage(200, 200, PixelTypes.Rgba32, 180f, 540f)]
+ [WithBlankImage(200, 200, PixelTypes.Rgba32, 270f, 630f)]
+ public void SweepGradientBrush_RendersFullSweep_Every90Degrees(TestImageProvider provider, float start, float end)
+ where TPixel : unmanaged, IPixel
+ => provider.VerifyOperation(
+ TolerantComparer,
+ image =>
+ {
+ Color red = Color.Red;
+ Color green = Color.Green;
+ Color blue = Color.Blue;
+ Color yellow = Color.Yellow;
+
+ SweepGradientBrush brush = new(
+ new Point(100, 100),
+ start,
+ end,
+ GradientRepetitionMode.None,
+ new ColorStop(0, red),
+ new ColorStop(0.25F, yellow),
+ new ColorStop(0.5F, green),
+ new ColorStop(0.75F, blue),
+ new ColorStop(1, red));
+
+ image.Mutate(x => x.Fill(brush));
+ },
+ $"start({start},end{end})",
+ false,
+ false);
+}
diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/Text/DrawTextOnImageTests.cs b/tests/ImageSharp.Drawing.Tests/Drawing/Text/DrawTextOnImageTests.cs
index 4ec2015d..e7379712 100644
--- a/tests/ImageSharp.Drawing.Tests/Drawing/Text/DrawTextOnImageTests.cs
+++ b/tests/ImageSharp.Drawing.Tests/Drawing/Text/DrawTextOnImageTests.cs
@@ -7,6 +7,7 @@
using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
+using SixLabors.ImageSharp.Drawing.Text;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Xunit.Abstractions;
@@ -31,7 +32,7 @@ public DrawTextOnImageTests(ITestOutputHelper output)
private ITestOutputHelper Output { get; }
[Theory]
- [WithSolidFilledImages(1276, 336, "White", PixelTypes.Rgba32, ColorFontSupport.MicrosoftColrFormat)]
+ [WithSolidFilledImages(1276, 336, "White", PixelTypes.Rgba32, ColorFontSupport.ColrV0)]
[WithSolidFilledImages(1276, 336, "White", PixelTypes.Rgba32, ColorFontSupport.None)]
public void EmojiFontRendering(TestImageProvider provider, ColorFontSupport colorFontSupport)
where TPixel : unmanaged, IPixel
@@ -58,7 +59,7 @@ public void EmojiFontRendering(TestImageProvider provider, Color
img.Mutate(i => i.DrawText(textOptions, text, color));
},
- $"ColorFontsEnabled-{colorFontSupport == ColorFontSupport.MicrosoftColrFormat}");
+ $"ColorFontsEnabled-{colorFontSupport == ColorFontSupport.ColrV0}");
}
[Theory]
@@ -686,28 +687,23 @@ public void CanDrawTextAlongPathHorizontal(TestImageProvider pro
bool parsed = Path.TryParseSvgPath(svgPath, out IPath path);
Assert.True(parsed);
+ const string text = "Quick brown fox jumps over the lazy dog.";
+
Font font = CreateFont(TestFonts.OpenSans, 13);
RichTextOptions textOptions = new(font)
{
WrappingLength = path.ComputeLength(),
VerticalAlignment = VerticalAlignment.Bottom,
HorizontalAlignment = HorizontalAlignment.Left,
+ TextRuns = [new RichTextRun { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Strikeout }],
};
- const string text = "Quick brown fox jumps over the lazy dog.";
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, path, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, path, textOptions);
-#if NET472
- provider.RunValidatingProcessorTest(
- c => c.Fill(Color.White).Draw(Color.Red, 1, path).Fill(Color.Black, glyphs),
- new { type = exampleImageKey },
- comparer: ImageComparer.TolerantPercentage(0.017f));
-#else
provider.RunValidatingProcessorTest(
c => c.Fill(Color.White).Draw(Color.Red, 1, path).Fill(Color.Black, glyphs),
new { type = exampleImageKey },
comparer: ImageComparer.TolerantPercentage(0.0025f));
-#endif
}
[Theory]
@@ -729,7 +725,7 @@ public void CanDrawTextAlongPathVertical(TestImageProvider provi
};
const string text = "Quick brown fox jumps over the lazy dog.";
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, path, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, path, textOptions);
provider.RunValidatingProcessorTest(
c => c.Fill(Color.White).Draw(Color.Red, 1, path).Fill(Color.Black, glyphs),
@@ -777,7 +773,7 @@ public void PathAndTextDrawingMatch(TestImageProvider provider)
VerticalAlignment = va,
};
- IPathCollection tb = TextBuilder.GenerateGlyphs(text, path, to);
+ IPathCollection tb = TextBuilder.GeneratePaths(text, path, to);
img.Mutate(
i => i.DrawLine(new SolidPen(Color.Red, 30), pathLine)
@@ -807,13 +803,10 @@ public void CanFillTextVertical(TestImageProvider provider)
]
};
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
-
- // TODO: This still leaves some holes when overlaying the text (CFF NotoSansKRRegular only). We need to fix this.
- DrawingOptions options = new() { ShapeOptions = new ShapeOptions { IntersectionRule = IntersectionRule.NonZero } };
+ IReadOnlyList glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
provider.RunValidatingProcessorTest(
- c => c.Fill(Color.White).Fill(options, Color.Black, glyphs),
+ c => c.Fill(Color.White).Fill(Color.Black, glyphs),
comparer: ImageComparer.TolerantPercentage(0.002f));
}
@@ -835,9 +828,8 @@ public void CanFillTextVerticalMixed(TestImageProvider provider)
]
};
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, textOptions);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, textOptions);
- // TODO: This still leaves some holes when overlaying the text (CFF NotoSansKRRegular only). We need to fix this.
DrawingOptions options = new() { ShapeOptions = new ShapeOptions { IntersectionRule = IntersectionRule.NonZero } };
provider.RunValidatingProcessorTest(
@@ -860,7 +852,8 @@ public void CanDrawTextVertical(TestImageProvider provider)
WrappingLength = 400,
LayoutMode = LayoutMode.VerticalLeftRight,
LineSpacing = 1.4F,
- TextRuns = [new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline }
+ TextRuns = [
+ new RichTextRun() { Start = 0, End = text.GetGraphemeCount(), TextDecorations = TextDecorations.Underline | TextDecorations.Strikeout | TextDecorations.Overline }
]
};
diff --git a/tests/ImageSharp.Drawing.Tests/Issues/Issue_330.cs b/tests/ImageSharp.Drawing.Tests/Issues/Issue_330.cs
index fa2f57fb..26e151dd 100644
--- a/tests/ImageSharp.Drawing.Tests/Issues/Issue_330.cs
+++ b/tests/ImageSharp.Drawing.Tests/Issues/Issue_330.cs
@@ -1,16 +1,12 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
using SixLabors.Fonts;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Drawing.Tests.Issues;
+
public class Issue_330
{
[Theory]
diff --git a/tests/ImageSharp.Drawing.Tests/Issues/Issue_462.cs b/tests/ImageSharp.Drawing.Tests/Issues/Issue_462.cs
new file mode 100644
index 00000000..378b0f53
--- /dev/null
+++ b/tests/ImageSharp.Drawing.Tests/Issues/Issue_462.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts;
+using SixLabors.Fonts.Unicode;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
+using SixLabors.ImageSharp.Drawing.Text;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Drawing.Tests.Issues;
+
+public class Issue_462
+{
+ [Theory]
+ [WithSolidFilledImages(492, 360, nameof(Color.White), PixelTypes.Rgba32, ColorFontSupport.ColrV1)]
+ [WithSolidFilledImages(492, 360, nameof(Color.White), PixelTypes.Rgba32, ColorFontSupport.Svg)]
+ public void CanDrawEmojiFont(TestImageProvider provider, ColorFontSupport support)
+ where TPixel : unmanaged, IPixel
+ {
+ Font font = CreateFont(TestFonts.NotoColorEmojiRegular, 100);
+ Font fallback = CreateFont(TestFonts.OpenSans, 100);
+ const string text = "a😨 b😅\r\nc🥲 d🤩";
+
+ RichTextOptions options = new(font)
+ {
+ ColorFontSupport = support,
+ LineSpacing = 1.8F,
+ FallbackFontFamilies = new[] { fallback.Family },
+ TextRuns = new List
+ {
+ new()
+ {
+ Start = 0,
+ End = text.GetGraphemeCount(),
+ TextDecorations = TextDecorations.Strikeout | TextDecorations.Underline | TextDecorations.Overline,
+ StrikeoutPen = new SolidPen(Color.Green, 11.3334F),
+ UnderlinePen = new SolidPen(Color.Blue, 15.5555F),
+ OverlinePen = new SolidPen(Color.Purple, 13.7777F)
+ }
+ }
+ };
+
+ provider.RunValidatingProcessorTest(
+ c => c.DrawText(options, text, Brushes.Solid(Color.Black)),
+ testOutputDetails: $"{support}-draw",
+ comparer: ImageComparer.TolerantPercentage(0.002f));
+
+ provider.RunValidatingProcessorTest(
+ c =>
+ {
+ Pen pen = Pens.Solid(Color.Black, 2);
+ c.Fill(pen.StrokeFill, pen, TextBuilder.GenerateGlyphs(text, options));
+ },
+ testOutputDetails: $"{support}-fill",
+ comparer: ImageComparer.TolerantPercentage(0.002f));
+ }
+
+ private static Font CreateFont(string fontName, float size)
+ => TestFontUtilities.GetFont(fontName, size);
+}
diff --git a/tests/ImageSharp.Drawing.Tests/Shapes/TextBuilderTests.cs b/tests/ImageSharp.Drawing.Tests/Shapes/TextBuilderTests.cs
index 8e394675..50faf00b 100644
--- a/tests/ImageSharp.Drawing.Tests/Shapes/TextBuilderTests.cs
+++ b/tests/ImageSharp.Drawing.Tests/Shapes/TextBuilderTests.cs
@@ -3,13 +3,14 @@
using System.Numerics;
using SixLabors.Fonts;
+using SixLabors.ImageSharp.Drawing.Text;
namespace SixLabors.ImageSharp.Drawing.Tests.Shapes;
public class TextBuilderTests
{
[Fact]
- public void TextBuilder_Bounds_AreCorrect()
+ public void TextBuilder_Bounds_AreCorrect_Paths()
{
Vector2 position = new(5, 5);
TextOptions options = new(TestFontUtilities.GetFont(TestFonts.OpenSans, 16))
@@ -19,9 +20,40 @@ public void TextBuilder_Bounds_AreCorrect()
const string text = "The quick brown fox jumps over the lazy fox";
- IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, options);
+ IPathCollection glyphs = TextBuilder.GeneratePaths(text, options);
RectangleF builderBounds = glyphs.Bounds;
+
+ FontRectangle directMeasured = TextMeasurer.MeasureBounds(text, options);
+ FontRectangle measuredBounds = new(new Vector2(0, 0), directMeasured.Size + directMeasured.Location);
+
+ Assert.Equal(measuredBounds.X, builderBounds.X);
+ Assert.Equal(measuredBounds.Y, builderBounds.Y);
+ Assert.Equal(measuredBounds.Width, builderBounds.Width);
+
+ // TextMeasurer will measure the full lineheight of the string.
+ // TextBuilder does not include line gaps following the descender since there
+ // is no path to include.
+ Assert.True(measuredBounds.Height >= builderBounds.Height);
+ }
+
+ [Fact]
+ public void TextBuilder_Bounds_AreCorrect_Glyphs()
+ {
+ Vector2 position = new(5, 5);
+ TextOptions options = new(TestFontUtilities.GetFont(TestFonts.OpenSans, 16))
+ {
+ Origin = position
+ };
+
+ const string text = "The quick brown fox jumps over the lazy fox";
+
+ IReadOnlyList glyphs = TextBuilder.GenerateGlyphs(text, options);
+
+ RectangleF builderBounds = glyphs
+ .Select(gp => gp.Bounds)
+ .Aggregate(RectangleF.Empty, RectangleF.Union);
+
FontRectangle directMeasured = TextMeasurer.MeasureBounds(text, options);
FontRectangle measuredBounds = new(new Vector2(0, 0), directMeasured.Size + directMeasured.Location);
diff --git a/tests/ImageSharp.Drawing.Tests/TestFonts.cs b/tests/ImageSharp.Drawing.Tests/TestFonts.cs
index 27c2c5b6..0b688d09 100644
--- a/tests/ImageSharp.Drawing.Tests/TestFonts.cs
+++ b/tests/ImageSharp.Drawing.Tests/TestFonts.cs
@@ -26,4 +26,6 @@ public static class TestFonts
public const string NotoSansKRRegular = "NotoSansKR-Regular.otf";
public const string NotoSerifKRRegular = "NotoSerifKR-Regular.otf";
+
+ public static string NotoColorEmojiRegular => "NotoColorEmoji-Regular.ttf";
}
diff --git a/tests/ImageSharp.Drawing.Tests/TestFonts/NotoColorEmoji-Regular.ttf b/tests/ImageSharp.Drawing.Tests/TestFonts/NotoColorEmoji-Regular.ttf
new file mode 100644
index 00000000..5d7a86f3
Binary files /dev/null and b/tests/ImageSharp.Drawing.Tests/TestFonts/NotoColorEmoji-Regular.ttf differ
diff --git a/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillLinearGradientBrushTests/RotatedGradient.png b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillLinearGradientBrushTests/RotatedGradient.png
new file mode 100644
index 00000000..def3b4f8
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillLinearGradientBrushTests/RotatedGradient.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b4ec1a3af5392c7ef46826ae4d660a7ddb4f9e079c983e16d8400f18f947b4b
+size 607
diff --git a/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(0,end360).png b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(0,end360).png
new file mode 100644
index 00000000..718c00be
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(0,end360).png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2f06ea31a467d5e59e0622c3834dd962f42f328cdcc8a253fcadbd38c6ea21e5
+size 10623
diff --git a/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(180,end540).png b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(180,end540).png
new file mode 100644
index 00000000..41a7c333
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(180,end540).png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:99d1013b8b1173c253532ea299ff7bbb13aee0d6bea688cb30edf989359dc2af
+size 10732
diff --git a/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(270,end630).png b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(270,end630).png
new file mode 100644
index 00000000..4de5fe10
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(270,end630).png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b51e8f78f25855e033b0be07ac4568617ce4c3c6a09df03e1ca5105df35f3b53
+size 10685
diff --git a/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(90,end450).png b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(90,end450).png
new file mode 100644
index 00000000..6edeb18d
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Drawing/GradientBrushes/FillSweepGradientBrushTests/SweepGradientBrush_RendersFullSweep_Every90Degrees_start(90,end450).png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:733a35be9abf41327757907092634424f0901b42a830daad4fb7959b50d129e2
+size 10523
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawRichTextAlongPathHorizontal_Solid100x100_(0,0,0,255)_RichText-Path-(spiral).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawRichTextAlongPathHorizontal_Solid100x100_(0,0,0,255)_RichText-Path-(spiral).png
index 6859b5cb..51a22b24 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawRichTextAlongPathHorizontal_Solid100x100_(0,0,0,255)_RichText-Path-(spiral).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawRichTextAlongPathHorizontal_Solid100x100_(0,0,0,255)_RichText-Path-(spiral).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1d31fa3597ccc94043ccba642c555b72b0406ec42cb0579898be516f15b370f3
-size 4212
+oid sha256:e3f7a0b2d52400407d09cabf13f8df283ebccc8c664e701a2802b804784cccbf
+size 4164
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank100x100_type-spiral.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank100x100_type-spiral.png
index f3fc9605..11f89ad2 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank100x100_type-spiral.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank100x100_type-spiral.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:52a24a4a88b0188078dc3368f66f50aa92c429651b401ca79e801db47363c314
-size 5082
+oid sha256:8f1a0cc896b8ea923ad094ea27cbb21fe83a1410409d13d107d853a6712a183a
+size 5353
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank120x120_type-triangle.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank120x120_type-triangle.png
index 426d0065..87ead1ad 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank120x120_type-triangle.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank120x120_type-triangle.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:95c2bb58b46703a0db1b4290e06383c528e1af87323bb3aa582f68710dd6da1b
-size 4219
+oid sha256:30d20da56fdfabdcac96b78b9365c29c5d2e30c5249d7153cd23de8cb5aea7f6
+size 4511
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank350x350_type-circle.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank350x350_type-circle.png
index f756fba4..565e5d6b 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank350x350_type-circle.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextAlongPathHorizontal_Rgba32_Blank350x350_type-circle.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c5198e6f7d8b8dcf5fb0e301580266669aa1b930f94054773d1eb25ee6d4c890
-size 9001
+oid sha256:2fa1cc25b8212b1260fe2e86bd9e2ca17341555368cfedd528882f4618002fc1
+size 9437
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVerticalMixed_Rgba32_Blank500x400.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVerticalMixed_Rgba32_Blank500x400.png
index b8ebbffb..156bd34b 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVerticalMixed_Rgba32_Blank500x400.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVerticalMixed_Rgba32_Blank500x400.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:09fd3e738d38c6dceaeec4286744fff8a43c4dc5e803368666666b1fdf532968
-size 13888
+oid sha256:a88ac7de5e96b68f6ff9e1b417f77125649764acbb2e95f72aa04de7891efe1e
+size 14164
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVertical_Rgba32_Blank500x400.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVertical_Rgba32_Blank500x400.png
index 25804b47..9ce3cfd8 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVertical_Rgba32_Blank500x400.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanDrawTextVertical_Rgba32_Blank500x400.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:dd69cc84711d235653e7e0b93cb5944130ea4e6813773158a483bde31b53655f
-size 12508
+oid sha256:99094889eb35d52fd20074391f72efa2137a20df93a61ac09c326891dc820f5c
+size 12851
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVerticalMixed_Rgba32_Blank500x400.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVerticalMixed_Rgba32_Blank500x400.png
index e06fd8b6..5fe5778e 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVerticalMixed_Rgba32_Blank500x400.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVerticalMixed_Rgba32_Blank500x400.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8d9a56365aec87bc9ec1a5f8dd7213b5d68f104ac71def4d5a0f4ebfabdd3a32
-size 11318
+oid sha256:c13f2c38c2c74ff5345a81df3ad2af039e3b5d86ec02d3863caf39ae915033ba
+size 11093
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVertical_Rgba32_Blank500x400.png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVertical_Rgba32_Blank500x400.png
index 2585e4e8..1d691b44 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVertical_Rgba32_Blank500x400.png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/CanFillTextVertical_Rgba32_Blank500x400.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:780093fabb4f665498adcddde53635cd68502341f8ff2a9fe66cf105a47e9af7
-size 4524
+oid sha256:008c1423db6c22ccf532bc502911a8cd896c0d878d0066f59844ce8edb3ad4c7
+size 4513
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextArabic_Solid500x300_(0,0,0,255)_RichText-Arabic-F(40).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextArabic_Solid500x300_(0,0,0,255)_RichText-Arabic-F(40).png
index f9aac5b6..9c9b34e1 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextArabic_Solid500x300_(0,0,0,255)_RichText-Arabic-F(40).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextArabic_Solid500x300_(0,0,0,255)_RichText-Arabic-F(40).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0e4dfba3f80e315d636582bab3e2f36f2631c1769792f340586b0d230a596412
-size 3931
+oid sha256:e7c7020f0d906773ba18f787b8045adb3e6b26bdc9ca72978bde278f8a7d61a6
+size 3889
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextRainbow_Solid500x200_(0,0,0,255)_RichText-Rainbow-F(32).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextRainbow_Solid500x200_(0,0,0,255)_RichText-Rainbow-F(32).png
index df6bf1f9..4e005679 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextRainbow_Solid500x200_(0,0,0,255)_RichText-Rainbow-F(32).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichTextRainbow_Solid500x200_(0,0,0,255)_RichText-Rainbow-F(32).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:311a216993a688d110aa3008360ba3315e4ae2ea964061f6cd14ec6f589bfa04
-size 8827
+oid sha256:4980f1302038def74531e96b23b4eebbd6b296f79da8be32e888f047e931226e
+size 8685
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x200_(0,0,0,255)_RichText-F(32).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x200_(0,0,0,255)_RichText-F(32).png
index 8fe52c7a..55ac43b6 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x200_(0,0,0,255)_RichText-F(32).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x200_(0,0,0,255)_RichText-F(32).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fc4639e3835cd6411a563a185eb19a49e7c307e66cda8df9ef7186d25c5b3854
-size 9322
+oid sha256:ac3e84d14c7e3e6f83259233c88e191c60d568d360df1ce9ef4598b9eaab65bf
+size 9222
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x300_(0,0,0,255)_RichText-F(40).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x300_(0,0,0,255)_RichText-F(40).png
index 75666501..ad6ee286 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x300_(0,0,0,255)_RichText-F(40).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/DrawRichText_Solid500x300_(0,0,0,255)_RichText-F(40).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8c63b67d55bdf45724f64ef0518c7962da912bf1bfc9fb38614c39715cf8f1c2
-size 11837
+oid sha256:c89d27ebda45bc3da2c7175d9139bebb8b824c042fee8b279cadb8009f32fbc6
+size 11690
diff --git a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/FontShapesAreRenderedCorrectlyWithAPenPatterned_Solid200x150_(255,255,255,255)_pen_SixLaborsSampleAB.woff-50-ABAB-(0,0).png b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/FontShapesAreRenderedCorrectlyWithAPenPatterned_Solid200x150_(255,255,255,255)_pen_SixLaborsSampleAB.woff-50-ABAB-(0,0).png
index 4353698b..0a714add 100644
--- a/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/FontShapesAreRenderedCorrectlyWithAPenPatterned_Solid200x150_(255,255,255,255)_pen_SixLaborsSampleAB.woff-50-ABAB-(0,0).png
+++ b/tests/Images/ReferenceOutput/Drawing/Text/DrawTextOnImageTests/FontShapesAreRenderedCorrectlyWithAPenPatterned_Solid200x150_(255,255,255,255)_pen_SixLaborsSampleAB.woff-50-ABAB-(0,0).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:74bc71359f75ab560474d53aa74d2e4a4d09807552020d426e6f1545b769b71b
-size 2821
+oid sha256:5842438b3650d5e7e1ebff9cd2416073ab5e71147ef073873222fc10ab32542d
+size 759
diff --git a/tests/Images/ReferenceOutput/Issue_330/OffsetTextOutlines_Rgba32_Solid2084x2084_(138,43,226,255).png b/tests/Images/ReferenceOutput/Issue_330/OffsetTextOutlines_Rgba32_Solid2084x2084_(138,43,226,255).png
index 1dee57d4..1baa9c43 100644
--- a/tests/Images/ReferenceOutput/Issue_330/OffsetTextOutlines_Rgba32_Solid2084x2084_(138,43,226,255).png
+++ b/tests/Images/ReferenceOutput/Issue_330/OffsetTextOutlines_Rgba32_Solid2084x2084_(138,43,226,255).png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b44ee8f82a88b7a6d8c0c908308aac7c9d90ad22370e2942b976c06cbdf2f1b3
-size 186486
+oid sha256:4c2938a1b6001c5c1d1906659b790119313014a52f97391d7edc1b0da9007d45
+size 115477
diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png
new file mode 100644
index 00000000..4cf04981
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a8b46c62c9837bbce721d010c7d2f4f5884e6cd053465d649a6ff36267430976
+size 31985
diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-fill.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-fill.png
new file mode 100644
index 00000000..cbde62cd
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-fill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2884047623f1b28a4ea2009a55ee4f4be0960c9f012cba7831c32f4245859eec
+size 10862
diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png
new file mode 100644
index 00000000..28b70b34
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:47e528f5b95689e0b1e381c6a7e9d7a8badd1523a4e132fbbf6b54d109cf07d0
+size 31963
diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-fill.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-fill.png
new file mode 100644
index 00000000..ce377606
--- /dev/null
+++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-fill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5d065961fc27ea7be274c92d591f5f26f332ce47acc7704a0f0c45fabde6a1b6
+size 10864