Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/ImageSharp.Drawing/Processing/PaintExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,25 @@ public static IImageProcessingContext Paint(

return source.ApplyProcessor(new PaintProcessor(options, action));
}

/// <summary>
/// Paints each image frame using the supplied drawing options and text drawing cache.
/// </summary>
/// <param name="source">The image processing context to paint.</param>
/// <param name="options">The drawing options applied when creating each frame canvas.</param>
/// <param name="textCache">The text drawing cache used by each frame canvas.</param>
/// <param name="action">The per-frame painting callback.</param>
/// <returns>The <see cref="IImageProcessingContext"/> so additional processing operations can be chained.</returns>
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));
}
}
23 changes: 23 additions & 0 deletions src/ImageSharp.Drawing/Processing/PaintProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,34 @@ public PaintProcessor(DrawingOptions options, CanvasAction action)
this.Action = action;
}

/// <summary>
/// Initializes a new instance of the <see cref="PaintProcessor"/> class.
/// </summary>
/// <param name="options">The drawing options used when creating each frame canvas.</param>
/// <param name="textCache">The text drawing cache used by each frame canvas.</param>
/// <param name="action">The per-frame painting callback.</param>
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;
}

/// <summary>
/// Gets the drawing options used when creating each frame canvas.
/// </summary>
public DrawingOptions Options { get; }

/// <summary>
/// Gets the text drawing cache used by each frame canvas, or <see langword="null"/> when each
/// frame canvas owns a private cache.
/// </summary>
public DrawingTextCache? TextCache { get; }

/// <summary>
/// Gets the per-frame painting callback.
/// </summary>
Expand Down
5 changes: 4 additions & 1 deletion src/ImageSharp.Drawing/Processing/PaintProcessor{TPixel}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ protected override void OnFrameApply(ImageFrame<TPixel> 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);
}
}
24 changes: 15 additions & 9 deletions src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder
/// </summary>
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;

/// <summary>
/// Initializes a new instance of the <see cref="RichTextGlyphRenderer"/> class.
/// </summary>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
/// </summary>
/// <param name="entries">The cached entry stream recorded by the build draw.</param>
/// <param name="currentBoundsLocation">The transformed bounding-box origin for the current glyph instance.</param>
private void EmitCachedLayeredGlyphOperations(List<GlyphRenderData> 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++)
Expand Down Expand Up @@ -943,7 +949,7 @@ private void EmitCachedLayeredGlyphOperations(List<GlyphRenderData> entries, Poi
/// </summary>
/// <param name="entry">The cached layer entry.</param>
/// <param name="currentBoundsLocation">The transformed bounding-box origin for the current glyph instance.</param>
/// <param name="paintTransform">The drawing transform with the glyph's positional delta appended.</param>
/// <param name="paintTransform">The glyph's positional delta before the drawing transform, followed by the drawing transform.</param>
/// <param name="replayDepth">The current group nesting depth.</param>
private void EmitCachedLayerFill(GlyphRenderData entry, PointF currentBoundsLocation, Matrix4x4 paintTransform, int replayDepth)
{
Expand Down Expand Up @@ -1040,13 +1046,13 @@ private void RecordMarker(GlyphRenderData entry)
/// <summary>
/// Appends a <see cref="GlyphRenderData"/> 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.
/// </summary>
/// <param name="renderData">The render data to append to the current key's entry list.</param>
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.
Expand Down
15 changes: 15 additions & 0 deletions tests/ImageSharp.Drawing.Tests/Drawing/ProcessWithCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,20 @@ public void CanvasActionWithOptions()

PaintProcessor processor = this.Verify<PaintProcessor>();
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<PaintProcessor>();
Assert.Equal(this.nonDefaultOptions, processor.Options);
Assert.Same(textCache, processor.TextCache);
}
}
27 changes: 27 additions & 0 deletions tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -291,6 +292,32 @@ public async Task ConcurrentPublication_EvictionAndClearPreserveAcquiredEntries(
await Task.WhenAll(tasks);
}

/// <summary>
/// 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.
/// </summary>
[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<Rgba32> warm = new(320, 160);
warm.Mutate(x => x.Paint(options, shared, canvas => canvas.DrawText(block, new PointF(16, 100), 320F, brush, null)));

using Image<Rgba32> 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<Rgba32> 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);
}

/// <summary>
/// Draws ordinary and decorated text, a positioned glyph run, and nested color layers.
/// </summary>
Expand Down
Loading