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.
///