Skip to content

Commit 22d08fc

Browse files
Merge pull request #422 from SixLabors/js/emoji-replay-color
Fix cached color glyph paint replay and add a Paint overload with a text cache
2 parents ef8c9c8 + f30b3ba commit 22d08fc

6 files changed

Lines changed: 105 additions & 10 deletions

File tree

‎src/ImageSharp.Drawing/Processing/PaintExtensions.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,25 @@ public static IImageProcessingContext Paint(
4242

4343
return source.ApplyProcessor(new PaintProcessor(options, action));
4444
}
45+
46+
/// <summary>
47+
/// Paints each image frame using the supplied drawing options and text drawing cache.
48+
/// </summary>
49+
/// <param name="source">The image processing context to paint.</param>
50+
/// <param name="options">The drawing options applied when creating each frame canvas.</param>
51+
/// <param name="textCache">The text drawing cache used by each frame canvas.</param>
52+
/// <param name="action">The per-frame painting callback.</param>
53+
/// <returns>The <see cref="IImageProcessingContext"/> so additional processing operations can be chained.</returns>
54+
public static IImageProcessingContext Paint(
55+
this IImageProcessingContext source,
56+
DrawingOptions options,
57+
DrawingTextCache textCache,
58+
CanvasAction action)
59+
{
60+
Guard.NotNull(options, nameof(options));
61+
Guard.NotNull(textCache, nameof(textCache));
62+
Guard.NotNull(action, nameof(action));
63+
64+
return source.ApplyProcessor(new PaintProcessor(options, textCache, action));
65+
}
4566
}

‎src/ImageSharp.Drawing/Processing/PaintProcessor.cs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,34 @@ public PaintProcessor(DrawingOptions options, CanvasAction action)
2525
this.Action = action;
2626
}
2727

28+
/// <summary>
29+
/// Initializes a new instance of the <see cref="PaintProcessor"/> class.
30+
/// </summary>
31+
/// <param name="options">The drawing options used when creating each frame canvas.</param>
32+
/// <param name="textCache">The text drawing cache used by each frame canvas.</param>
33+
/// <param name="action">The per-frame painting callback.</param>
34+
public PaintProcessor(DrawingOptions options, DrawingTextCache textCache, CanvasAction action)
35+
{
36+
Guard.NotNull(options, nameof(options));
37+
Guard.NotNull(textCache, nameof(textCache));
38+
Guard.NotNull(action, nameof(action));
39+
40+
this.Options = options;
41+
this.TextCache = textCache;
42+
this.Action = action;
43+
}
44+
2845
/// <summary>
2946
/// Gets the drawing options used when creating each frame canvas.
3047
/// </summary>
3148
public DrawingOptions Options { get; }
3249

50+
/// <summary>
51+
/// Gets the text drawing cache used by each frame canvas, or <see langword="null"/> when each
52+
/// frame canvas owns a private cache.
53+
/// </summary>
54+
public DrawingTextCache? TextCache { get; }
55+
3356
/// <summary>
3457
/// Gets the per-frame painting callback.
3558
/// </summary>

‎src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ protected override void OnFrameApply(ImageFrame<TPixel> source)
4040
// The callback only records work. Disposing the canvas finalizes open state
4141
// (layers, clips) and replays the recorded timeline into the frame, so the
4242
// using scope is what commits the painting.
43-
using DrawingCanvas canvas = source.CreateCanvas(this.Configuration, this.definition.Options);
43+
using DrawingCanvas canvas = this.definition.TextCache is null
44+
? source.CreateCanvas(this.Configuration, this.definition.Options)
45+
: source.CreateCanvas(this.Configuration, this.definition.Options, this.definition.TextCache);
46+
4447
this.action(canvas);
4548
}
4649
}

‎src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder
206206
/// </summary>
207207
private PointF currentTransformedBoundsLocation;
208208

209+
// The current glyph's metric origin before the drawing transform. Cached paint brushes
210+
// are re-created from paints expressed in this space, so the replay shifts them by the
211+
// difference between this origin and the build-time origin before the drawing transform.
212+
private Vector2 currentLocalBoundsLocation;
213+
209214
/// <summary>
210215
/// Initializes a new instance of the <see cref="RichTextGlyphRenderer"/> class.
211216
/// </summary>
@@ -337,6 +342,7 @@ protected override bool BeginGlyph(in FontRectangle bounds, in GlyphRendererPara
337342
this.currentGlyphClip = RectangleF.FromLTRB(min.X, min.Y, max.X, max.Y);
338343
}
339344

345+
this.currentLocalBoundsLocation = bounds.Location;
340346
if (!this.noCache)
341347
{
342348
// Transform the font-metric bounds by the drawing transform so that the size
@@ -886,16 +892,16 @@ private void EmitCachedGlyphOperations(GlyphRenderData renderData, PointF curren
886892
/// decoration-free cache hit when the font engine is told to skip the glyph entirely,
887893
/// so no outline is decoded and no path graph is built. Geometry replays from the
888894
/// anchored per-layer paths, group bounds and the glyph clip are recomputed per draw,
889-
/// and paint brushes re-convert with the glyph's positional delta appended to the
890-
/// drawing transform, because converted brushes bake device coordinates.
895+
/// and paint brushes re-convert from their paints, which are expressed in the space
896+
/// before the drawing transform, shifted by the glyph's positional delta in that space
897+
/// and then transformed like the build draw's geometry.
891898
/// </summary>
892899
/// <param name="entries">The cached entry stream recorded by the build draw.</param>
893900
/// <param name="currentBoundsLocation">The transformed bounding-box origin for the current glyph instance.</param>
894901
private void EmitCachedLayeredGlyphOperations(List<GlyphRenderData> entries, PointF currentBoundsLocation)
895902
{
896-
Vector2 currentOrigin = currentBoundsLocation;
897-
Vector2 delta = currentOrigin - entries[0].SourceOrigin;
898-
Matrix4x4 paintTransform = this.drawingOptions.Transform * Matrix4x4.CreateTranslation(delta.X, delta.Y, 0F);
903+
Vector2 delta = this.currentLocalBoundsLocation - entries[0].SourceOrigin;
904+
Matrix4x4 paintTransform = Matrix4x4.CreateTranslation(delta.X, delta.Y, 0F) * this.drawingOptions.Transform;
899905
int replayDepth = 0;
900906

901907
for (int i = 0; i < entries.Count; i++)
@@ -943,7 +949,7 @@ private void EmitCachedLayeredGlyphOperations(List<GlyphRenderData> entries, Poi
943949
/// </summary>
944950
/// <param name="entry">The cached layer entry.</param>
945951
/// <param name="currentBoundsLocation">The transformed bounding-box origin for the current glyph instance.</param>
946-
/// <param name="paintTransform">The drawing transform with the glyph's positional delta appended.</param>
952+
/// <param name="paintTransform">The glyph's positional delta before the drawing transform, followed by the drawing transform.</param>
947953
/// <param name="replayDepth">The current group nesting depth.</param>
948954
private void EmitCachedLayerFill(GlyphRenderData entry, PointF currentBoundsLocation, Matrix4x4 paintTransform, int replayDepth)
949955
{
@@ -1040,13 +1046,13 @@ private void RecordMarker(GlyphRenderData entry)
10401046
/// <summary>
10411047
/// Appends a <see cref="GlyphRenderData"/> entry to the private pending glyph list.
10421048
/// Creates the list on the first callback that produces cacheable data. Every entry
1043-
/// is stamped with the glyph's build-time transformed metric origin so layered replays
1044-
/// can derive the positional delta for paint brushes.
1049+
/// is stamped with the glyph's build-time metric origin before the drawing transform so
1050+
/// layered replays can derive the positional delta for paint brushes.
10451051
/// </summary>
10461052
/// <param name="renderData">The render data to append to the current key's entry list.</param>
10471053
private void UpdateCache(GlyphRenderData renderData)
10481054
{
1049-
renderData.SourceOrigin = this.currentTransformedBoundsLocation;
1055+
renderData.SourceOrigin = this.currentLocalBoundsLocation;
10501056

10511057
// Path bounds use a lazy nullable-struct field. Materialize it while the translated
10521058
// path is still private; later canvases may read these bounds concurrently.

‎tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,20 @@ public void CanvasActionWithOptions()
3232

3333
PaintProcessor processor = this.Verify<PaintProcessor>();
3434
Assert.Equal(this.nonDefaultOptions, processor.Options);
35+
Assert.Null(processor.TextCache);
36+
}
37+
38+
[Fact]
39+
public void CanvasActionWithOptionsAndTextCache()
40+
{
41+
DrawingTextCache textCache = new();
42+
this.operations.Paint(
43+
this.nonDefaultOptions,
44+
textCache,
45+
canvas => canvas.Clear(Brushes.Solid(Color.Red)));
46+
47+
PaintProcessor processor = this.Verify<PaintProcessor>();
48+
Assert.Equal(this.nonDefaultOptions, processor.Options);
49+
Assert.Same(textCache, processor.TextCache);
3550
}
3651
}

‎tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using SixLabors.ImageSharp.Drawing.Processing.Processors.Text;
1111
using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison;
1212
using SixLabors.ImageSharp.PixelFormats;
13+
using SixLabors.ImageSharp.Processing;
1314

1415
namespace SixLabors.ImageSharp.Drawing.Tests.Processing;
1516

@@ -291,6 +292,32 @@ public async Task ConcurrentPublication_EvictionAndClearPreserveAcquiredEntries(
291292
await Task.WhenAll(tasks);
292293
}
293294

295+
/// <summary>
296+
/// Verifies a cached layered glyph replayed at a new position matches a fresh build there.
297+
/// Paint brushes are re-created from paints expressed before the drawing transform, so a
298+
/// replay that keeps the build position would sample a gradient outside the glyph.
299+
/// </summary>
300+
[Fact]
301+
public void LayeredGlyph_ReplayAtNewPositionMatchesFreshBuild()
302+
{
303+
Font emojiFont = TestFontUtilities.GetFont(TestFonts.NotoColorEmojiRegular, 48);
304+
TextBlock block = new("😀", new RichTextOptions(emojiFont) { ColorFontSupport = ColorFontSupport.ColrV1 });
305+
DrawingTextCache shared = new();
306+
DrawingOptions options = new();
307+
Brush brush = Brushes.Solid(Color.Red);
308+
309+
using Image<Rgba32> warm = new(320, 160);
310+
warm.Mutate(x => x.Paint(options, shared, canvas => canvas.DrawText(block, new PointF(16, 100), 320F, brush, null)));
311+
312+
using Image<Rgba32> expected = new(320, 160);
313+
expected.Mutate(x => x.Paint(options, new DrawingTextCache(), canvas => canvas.DrawText(block, new PointF(200, 20), 320F, brush, null)));
314+
315+
using Image<Rgba32> actual = new(320, 160);
316+
actual.Mutate(x => x.Paint(options, shared, canvas => canvas.DrawText(block, new PointF(200, 20), 320F, brush, null)));
317+
318+
ImageComparer.Exact.VerifySimilarity(expected, actual);
319+
}
320+
294321
/// <summary>
295322
/// Draws ordinary and decorated text, a positioned glyph run, and nested color layers.
296323
/// </summary>

0 commit comments

Comments
 (0)