diff --git a/src/ImageSharp.Drawing/Processing/PaintExtensions.cs b/src/ImageSharp.Drawing/Processing/PaintExtensions.cs index 148ebea1..e624a548 100644 --- a/src/ImageSharp.Drawing/Processing/PaintExtensions.cs +++ b/src/ImageSharp.Drawing/Processing/PaintExtensions.cs @@ -42,4 +42,25 @@ public static IImageProcessingContext Paint( return source.ApplyProcessor(new PaintProcessor(options, action)); } + + /// + /// Paints each image frame using the supplied drawing options and text drawing cache. + /// + /// The image processing context to paint. + /// The drawing options applied when creating each frame canvas. + /// The text drawing cache used by each frame canvas. + /// The per-frame painting callback. + /// The so additional processing operations can be chained. + public static IImageProcessingContext Paint( + this IImageProcessingContext source, + DrawingOptions options, + DrawingTextCache textCache, + CanvasAction action) + { + Guard.NotNull(options, nameof(options)); + Guard.NotNull(textCache, nameof(textCache)); + Guard.NotNull(action, nameof(action)); + + return source.ApplyProcessor(new PaintProcessor(options, textCache, action)); + } } diff --git a/src/ImageSharp.Drawing/Processing/PaintProcessor.cs b/src/ImageSharp.Drawing/Processing/PaintProcessor.cs index 870bfcec..bb833993 100644 --- a/src/ImageSharp.Drawing/Processing/PaintProcessor.cs +++ b/src/ImageSharp.Drawing/Processing/PaintProcessor.cs @@ -25,11 +25,34 @@ public PaintProcessor(DrawingOptions options, CanvasAction action) this.Action = action; } + /// + /// Initializes a new instance of the class. + /// + /// The drawing options used when creating each frame canvas. + /// The text drawing cache used by each frame canvas. + /// The per-frame painting callback. + public PaintProcessor(DrawingOptions options, DrawingTextCache textCache, CanvasAction action) + { + Guard.NotNull(options, nameof(options)); + Guard.NotNull(textCache, nameof(textCache)); + Guard.NotNull(action, nameof(action)); + + this.Options = options; + this.TextCache = textCache; + this.Action = action; + } + /// /// Gets the drawing options used when creating each frame canvas. /// public DrawingOptions Options { get; } + /// + /// Gets the text drawing cache used by each frame canvas, or when each + /// frame canvas owns a private cache. + /// + public DrawingTextCache? TextCache { get; } + /// /// Gets the per-frame painting callback. /// diff --git a/src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs b/src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs index c9168212..2b2935f5 100644 --- a/src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs @@ -40,7 +40,10 @@ protected override void OnFrameApply(ImageFrame source) // The callback only records work. Disposing the canvas finalizes open state // (layers, clips) and replays the recorded timeline into the frame, so the // using scope is what commits the painting. - using DrawingCanvas canvas = source.CreateCanvas(this.Configuration, this.definition.Options); + using DrawingCanvas canvas = this.definition.TextCache is null + ? source.CreateCanvas(this.Configuration, this.definition.Options) + : source.CreateCanvas(this.Configuration, this.definition.Options, this.definition.TextCache); + this.action(canvas); } } diff --git a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs index c7b17e7a..13da30d5 100644 --- a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs +++ b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs @@ -206,6 +206,11 @@ internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder /// private PointF currentTransformedBoundsLocation; + // The current glyph's metric origin before the drawing transform. Cached paint brushes + // are re-created from paints expressed in this space, so the replay shifts them by the + // difference between this origin and the build-time origin before the drawing transform. + private Vector2 currentLocalBoundsLocation; + /// /// Initializes a new instance of the class. /// @@ -337,6 +342,7 @@ protected override bool BeginGlyph(in FontRectangle bounds, in GlyphRendererPara this.currentGlyphClip = RectangleF.FromLTRB(min.X, min.Y, max.X, max.Y); } + this.currentLocalBoundsLocation = bounds.Location; if (!this.noCache) { // Transform the font-metric bounds by the drawing transform so that the size @@ -886,16 +892,16 @@ private void EmitCachedGlyphOperations(GlyphRenderData renderData, PointF curren /// decoration-free cache hit when the font engine is told to skip the glyph entirely, /// so no outline is decoded and no path graph is built. Geometry replays from the /// anchored per-layer paths, group bounds and the glyph clip are recomputed per draw, - /// and paint brushes re-convert with the glyph's positional delta appended to the - /// drawing transform, because converted brushes bake device coordinates. + /// and paint brushes re-convert from their paints, which are expressed in the space + /// before the drawing transform, shifted by the glyph's positional delta in that space + /// and then transformed like the build draw's geometry. /// /// The cached entry stream recorded by the build draw. /// The transformed bounding-box origin for the current glyph instance. private void EmitCachedLayeredGlyphOperations(List entries, PointF currentBoundsLocation) { - Vector2 currentOrigin = currentBoundsLocation; - Vector2 delta = currentOrigin - entries[0].SourceOrigin; - Matrix4x4 paintTransform = this.drawingOptions.Transform * Matrix4x4.CreateTranslation(delta.X, delta.Y, 0F); + Vector2 delta = this.currentLocalBoundsLocation - entries[0].SourceOrigin; + Matrix4x4 paintTransform = Matrix4x4.CreateTranslation(delta.X, delta.Y, 0F) * this.drawingOptions.Transform; int replayDepth = 0; for (int i = 0; i < entries.Count; i++) @@ -943,7 +949,7 @@ private void EmitCachedLayeredGlyphOperations(List entries, Poi /// /// The cached layer entry. /// The transformed bounding-box origin for the current glyph instance. - /// The drawing transform with the glyph's positional delta appended. + /// The glyph's positional delta before the drawing transform, followed by the drawing transform. /// The current group nesting depth. private void EmitCachedLayerFill(GlyphRenderData entry, PointF currentBoundsLocation, Matrix4x4 paintTransform, int replayDepth) { @@ -1040,13 +1046,13 @@ private void RecordMarker(GlyphRenderData entry) /// /// Appends a entry to the private pending glyph list. /// Creates the list on the first callback that produces cacheable data. Every entry - /// is stamped with the glyph's build-time transformed metric origin so layered replays - /// can derive the positional delta for paint brushes. + /// is stamped with the glyph's build-time metric origin before the drawing transform so + /// layered replays can derive the positional delta for paint brushes. /// /// The render data to append to the current key's entry list. private void UpdateCache(GlyphRenderData renderData) { - renderData.SourceOrigin = this.currentTransformedBoundsLocation; + renderData.SourceOrigin = this.currentLocalBoundsLocation; // Path bounds use a lazy nullable-struct field. Materialize it while the translated // path is still private; later canvases may read these bounds concurrently. diff --git a/tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs b/tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs index e01fe17d..56051250 100644 --- a/tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs +++ b/tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs @@ -32,5 +32,20 @@ public void CanvasActionWithOptions() PaintProcessor processor = this.Verify(); Assert.Equal(this.nonDefaultOptions, processor.Options); + Assert.Null(processor.TextCache); + } + + [Fact] + public void CanvasActionWithOptionsAndTextCache() + { + DrawingTextCache textCache = new(); + this.operations.Paint( + this.nonDefaultOptions, + textCache, + canvas => canvas.Clear(Brushes.Solid(Color.Red))); + + PaintProcessor processor = this.Verify(); + Assert.Equal(this.nonDefaultOptions, processor.Options); + Assert.Same(textCache, processor.TextCache); } } diff --git a/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs index 8cc12e8a..81110c33 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs @@ -10,6 +10,7 @@ using SixLabors.ImageSharp.Drawing.Processing.Processors.Text; using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; namespace SixLabors.ImageSharp.Drawing.Tests.Processing; @@ -291,6 +292,32 @@ public async Task ConcurrentPublication_EvictionAndClearPreserveAcquiredEntries( await Task.WhenAll(tasks); } + /// + /// Verifies a cached layered glyph replayed at a new position matches a fresh build there. + /// Paint brushes are re-created from paints expressed before the drawing transform, so a + /// replay that keeps the build position would sample a gradient outside the glyph. + /// + [Fact] + public void LayeredGlyph_ReplayAtNewPositionMatchesFreshBuild() + { + Font emojiFont = TestFontUtilities.GetFont(TestFonts.NotoColorEmojiRegular, 48); + TextBlock block = new("😀", new RichTextOptions(emojiFont) { ColorFontSupport = ColorFontSupport.ColrV1 }); + DrawingTextCache shared = new(); + DrawingOptions options = new(); + Brush brush = Brushes.Solid(Color.Red); + + using Image warm = new(320, 160); + warm.Mutate(x => x.Paint(options, shared, canvas => canvas.DrawText(block, new PointF(16, 100), 320F, brush, null))); + + using Image expected = new(320, 160); + expected.Mutate(x => x.Paint(options, new DrawingTextCache(), canvas => canvas.DrawText(block, new PointF(200, 20), 320F, brush, null))); + + using Image actual = new(320, 160); + actual.Mutate(x => x.Paint(options, shared, canvas => canvas.DrawText(block, new PointF(200, 20), 320F, brush, null))); + + ImageComparer.Exact.VerifySimilarity(expected, actual); + } + /// /// Draws ordinary and decorated text, a positioned glyph run, and nested color layers. ///