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