From 715e0d322422bf23578063dd6ecd5977d308957b Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 16 Sep 2026 09:42:06 +1000 Subject: [PATCH 1/2] Add a Paint overload that takes a DrawingTextCache The Paint image processing extension created a private text cache for each frame canvas. The new overload passes an application-owned cache to every frame canvas so glyph geometry is shared across draws, matching the CreateCanvas overloads. --- .../Processing/PaintExtensions.cs | 21 +++++++++++++++++ .../Processing/PaintProcessor.cs | 23 +++++++++++++++++++ .../Processing/PaintProcessor{TPixel}.cs | 5 +++- .../Drawing/ProcessWithCanvas.cs | 15 ++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) 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/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); } } From f30b3badcb7c1693f7581b8e6ead315395e688f8 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 16 Sep 2026 09:42:07 +1000 Subject: [PATCH 2/2] Replay cached layered glyph paints in text space Cached color glyph layers re-create their paint brushes on replay. The cache stored the device-space glyph origin and appended the device-space delta after the drawing transform, so a draw whose transform carries an origin applied that offset twice and the gradient drifted off the glyph. Store the glyph origin before the drawing transform and apply the delta before it. --- .../Processing/RichTextGlyphRenderer.cs | 24 ++++++++++------- .../Processing/DrawingTextCacheTests.cs | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) 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/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. ///