diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj index 16b60759..c1795038 100644 --- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj +++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj @@ -44,8 +44,8 @@ - - + + diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs index 4fee64a5..81e40129 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs @@ -72,26 +72,6 @@ public sealed class DrawingCanvas : DrawingCanvas /// private readonly DrawingTextCache textCache; - /// - /// Reusable operation list handed to each text renderer. Hosted by the text cache because - /// canvases are per-frame objects; sharing the cache-owned list keeps its capacity across - /// frames instead of regrowing a fresh list of large operation structs per draw. - /// - private readonly List textOperations; - - /// - /// Reusable sort buffer for , hosted by the text cache for - /// the same reason as . Only pass and index pairs are sorted; - /// the operations themselves stay in place so the per-draw sort moves eight bytes per - /// entry instead of the full operation struct. - /// - private readonly List<(byte RenderPass, int Sequence)> textOperationSortBuffer; - - /// - /// Reusable stack pairing the begin and end commands for nested text composite layers. - /// - private readonly List textCompositeLayerStack; - /// /// Initializes a new instance of the class. /// @@ -303,9 +283,6 @@ private DrawingCanvas( this.targetFrame = targetFrame; this.batcher = batcher; this.textCache = textCache; - this.textOperations = textCache.OperationScratch; - this.textOperationSortBuffer = textCache.OperationSortScratch; - this.textCompositeLayerStack = textCache.CompositeLayerScratch; this.ownsBatcher = ownsBatcher; this.ownsTextCache = ownsTextCache; this.pendingImageResources = pendingImageResources; @@ -869,11 +846,11 @@ configuredOptions.VisibleBounds is null && }; } - using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, configuredPath, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, configuredPath, pen, brush, this.textCache); TextRenderer renderer = new(glyphRenderer); renderer.Render(text, configuredOptions); - this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, effectiveOptions); } /// @@ -900,7 +877,7 @@ public override void DrawText( Matrix4x4.CreateTranslation(location.X, location.Y, 0) * effectiveOptions.Transform, effectiveOptions.TextContrast); - using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.textCache); if (TryGetVisibleTextBounds(state, placedOptions.Transform, out FontRectangle visibleBounds)) { textBlock.RenderTo(glyphRenderer, wrappingLength, visibleBounds); @@ -910,7 +887,7 @@ public override void DrawText( textBlock.RenderTo(glyphRenderer, wrappingLength); } - this.DrawTextOperations(glyphRenderer.DrawingOperations, placedOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, placedOptions); } /// @@ -929,10 +906,10 @@ public override void DrawText( DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; - using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.textCache); textBlock.RenderTo(glyphRenderer, wrappingLength); - this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, effectiveOptions); } /// @@ -958,10 +935,10 @@ public override void DrawText( Matrix4x4.CreateTranslation(location.X, location.Y, 0) * effectiveOptions.Transform, effectiveOptions.TextContrast); - using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.textCache); lineLayout.RenderTo(glyphRenderer); - this.DrawTextOperations(glyphRenderer.DrawingOperations, placedOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, placedOptions); } /// @@ -979,10 +956,10 @@ public override void DrawText( DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; - using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.textCache); lineLayout.RenderTo(glyphRenderer); - this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, effectiveOptions); } /// @@ -999,11 +976,11 @@ public override void DrawText( DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; - using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path: null, pen, brush, this.textCache, this.textOperations); + using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path: null, pen, brush, this.textCache); TextRenderer renderer = new(glyphRenderer); renderer.Render(glyphId, options); - this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions); + this.DrawTextOperations(glyphRenderer.DrawingOperations, glyphRenderer.Scratch, effectiveOptions); } /// @@ -1023,11 +1000,11 @@ public override void DrawText(ReadOnlySpan glyphIds, ReadOnlySpan @@ -1801,17 +1778,20 @@ private static IPath GetPositionedGlyphPath(DrawingTextCache.RunPathCacheEntry e /// Converts rendered text operations to composition commands and submits them to the batcher. /// /// Text drawing operations produced by glyph layout/rendering. + /// The working buffers leased by this draw's renderer. /// Drawing options applied to each operation. - private void DrawTextOperations(List operations, DrawingOptions drawingOptions) + private void DrawTextOperations( + List operations, + DrawingTextCache.DrawingScratch scratch, + DrawingOptions drawingOptions) { // Enforce render-pass ordering while preserving original emission order inside each // pass. This preserves overlapping color-font layer compositing semantics (for // example emoji mouth/teeth layers) and keeps the composite group markers paired // with the fill operations they contain. - // The cache-owned buffer keeps its capacity across draws; draw calls never overlap on - // one canvas. - List<(byte RenderPass, int Sequence)> entries = this.textOperationSortBuffer; - entries.Clear(); + // The renderer leases these buffers until command submission completes. Other canvases + // sharing the cache rent different buffers, while sequential draws retain capacity. + List<(byte RenderPass, int Sequence)> entries = scratch.SortBuffer; // Queued glyph commands never carry the canvas transform: glyph geometry arrives with // it already applied, and the sub-pixel remainder rides the command itself. One shared @@ -1832,8 +1812,7 @@ private void DrawTextOperations(List operations, DrawingOption return cmp != 0 ? cmp : a.Sequence.CompareTo(b.Sequence); }); - List compositeLayers = this.textCompositeLayerStack; - compositeLayers.Clear(); + List compositeLayers = scratch.CompositeLayers; DrawingCanvasState state = this.ResolveState(); for (int i = 0; i < entries.Count; i++) @@ -1879,11 +1858,6 @@ private void DrawTextOperations(List operations, DrawingOption this.batcher.AddStrokePath(((StrokePathCompositionSceneCommand)command).Command); } } - - // The buffers outlive the canvas (the text cache hosts them), so drop the layer - // references now rather than rooting the final draw's state until the next draw. - entries.Clear(); - compositeLayers.Clear(); } /// diff --git a/src/ImageSharp.Drawing/Processing/DrawingTextCache.cs b/src/ImageSharp.Drawing/Processing/DrawingTextCache.cs index fbb42304..e8d025e3 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingTextCache.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingTextCache.cs @@ -11,15 +11,7 @@ namespace SixLabors.ImageSharp.Drawing.Processing; /// Stores reusable text drawing data shared by one or more drawing canvases. /// /// -/// Two tiers are cached. The glyph cache holds one flattened outline per glyph, keyed by glyph id, -/// size and pen; it is the base layer that avoids re-flattening the same glyph and is shared by -/// every run. The run-path cache is derived from it: the glyphs of a whole uniform run merged into a -/// single positioned path, so that redrawing the run collapses to one composition command instead of -/// one per glyph. The run path is keyed in run-local space, so the same run content drawn at any -/// position, including a fractionally scrolled one, is a hit. The cost is memory, because the merged -/// path holds a copy of each glyph's geometry; far fewer run paths than glyph entries are kept, only -/// whole-run repeats benefit, and a run that differs by a single glyph misses and falls back to the -/// per-glyph commands. +/// This class is thread-safe. Concurrent drawing operations must use separate canvas instances. /// public sealed class DrawingTextCache { @@ -35,6 +27,31 @@ public sealed class DrawingTextCache /// private const int RunPathCapacityDivisor = 4; + /// + /// Protects cache metadata and the scratch pool, never glyph construction or drawing. + /// + private readonly object sync = new(); + + /// + /// Idle draw buffers. Renting transfers exclusive ownership until the renderer is disposed. + /// + private readonly Stack scratchPool = new(); + + // Two tiers are cached. The glyph cache holds reusable outline data keyed by glyph identity, + // size and pen. It avoids rebuilding glyph outlines and supplies the geometry used by every + // run. Layered glyphs retain their complete layer and composite-group sequence. + // + // The run-path cache is derived from the glyph cache: the glyphs of a whole uniform run are + // merged into a single positioned path, so redrawing the run requires one composition command + // instead of one per glyph. The key uses run-local positions, allowing the same run to be + // reused at different locations, including during fractional scrolling. Only whole-run + // repeats benefit; a run that differs by one glyph misses and reuses the individual glyph + // entries to construct its own combined path. + // + // Combined paths retain additional geometry, so the run-path cache has a smaller capacity + // than the glyph cache. This limits memory retention while preserving reuse of individual + // glyphs across different runs. + // // Both caches are LRU: the dictionary provides O(1) lookup while the linked list // tracks usage order (most recently used at the head, eviction from the tail). @@ -96,42 +113,76 @@ public DrawingTextCache(int capacity) /// /// Gets the number of glyph cache entries. Run-path entries are not included. /// - public int Count => this.entries.Count; + public int Count + { + get + { + lock (this.sync) + { + return this.entries.Count; + } + } + } /// - /// Gets the reusable drawing-operation scratch list handed to text renderers. Canvases are - /// per-frame objects while this cache survives across frames, so hosting the scratch here - /// keeps its capacity instead of regrowing a list of large operation structs every draw. - /// The list is cleared at the start of each text draw; like the caches on this type it - /// assumes single-threaded use. + /// Rents exclusive working buffers for one text draw, retaining capacity across frames. /// - internal List OperationScratch { get; } = []; + /// The working buffers owned by the renderer until it is disposed. + internal DrawingScratch RentScratch() + { + lock (this.sync) + { + if (this.scratchPool.Count > 0) + { + return this.scratchPool.Pop(); + } + } - /// - /// Gets the reusable render-pass sort buffer used before text operations are lowered to - /// composition commands, hosted here for the same lifetime reason as - /// . Entries index into the operation list so the sort - /// moves pass and index pairs rather than whole operation structs. - /// - internal List<(byte RenderPass, int Sequence)> OperationSortScratch { get; } = []; + return new DrawingScratch(); + } /// - /// Gets the reusable stack used to pair nested text composite layer commands. + /// Returns working buffers after all operations have been consumed or the draw has failed. /// - internal List CompositeLayerScratch { get; } = []; + /// The exclusively owned working buffers to return. + internal void ReturnScratch(DrawingScratch scratch) + { + // Drop per-draw references outside the lock while retaining the lists' capacity. + scratch.Operations.Clear(); + scratch.SortBuffer.Clear(); + scratch.CompositeLayers.Clear(); + + lock (this.sync) + { + // Match the backend worker pool's bound so a concurrency spike does not retain + // arbitrarily many large operation buffers for the lifetime of this cache. + if (this.scratchPool.Count < Environment.ProcessorCount) + { + this.scratchPool.Push(scratch); + } + } + } /// /// Removes all cached text drawing data. /// + /// + /// Draws already in progress can continue using previously cached data and can populate + /// the cache again after this method returns. + /// public void Clear() { - this.entries.Clear(); - this.usage.Clear(); - this.runPathEntries.Clear(); - this.runPathUsage.Clear(); - this.OperationScratch.Clear(); - this.OperationSortScratch.Clear(); - this.CompositeLayerScratch.Clear(); + lock (this.sync) + { + this.entries.Clear(); + this.usage.Clear(); + this.runPathEntries.Clear(); + this.runPathUsage.Clear(); + + // Active renderers own their buffers and cached values independently of these + // indexes. Clearing must not mutate either while a draw is consuming them. + this.scratchPool.Clear(); + } } /// @@ -144,47 +195,51 @@ public void Clear() /// internal bool TryGetValue(RichTextGlyphRenderer.CacheKey key, [NotNullWhen(true)] out List? value) { - if (!this.entries.TryGetValue(key, out LinkedListNode? node)) + lock (this.sync) { - value = null; - return false; - } + if (!this.entries.TryGetValue(key, out LinkedListNode? node)) + { + value = null; + return false; + } - // Move the hit to the head so the least recently used entry stays at the tail. - this.usage.Remove(node); - this.usage.AddFirst(node); - value = node.Value.Value; - return true; + // Move the hit to the head so the least recently used entry stays at the tail. + this.usage.Remove(node); + this.usage.AddFirst(node); + value = node.Value.Value; + return true; + } } /// - /// Gets existing glyph drawing data for the specified key, or creates a new cache entry. + /// Publishes a complete glyph. Ownership of the list transfers to the cache; it must not + /// be modified after this call, including when another draw has already populated the key. /// /// The glyph cache key. - /// - /// The glyph drawing data associated with . - /// - internal List GetOrAdd(RichTextGlyphRenderer.CacheKey key) + /// The complete glyph entries in callback order. + internal void Add(RichTextGlyphRenderer.CacheKey key, List value) { - if (this.TryGetValue(key, out List? value)) + lock (this.sync) { - return value; - } + // Misses build outside the lock. Keep the first complete result if two draws + // built the same key, rather than combining their layer sequences. + if (this.entries.ContainsKey(key)) + { + return; + } - value = []; - LinkedListNode node = new(new Entry(key, value)); - this.usage.AddFirst(node); - this.entries.Add(key, node); + LinkedListNode node = new(new Entry(key, value)); + this.usage.AddFirst(node); + this.entries.Add(key, node); - // Evict the least recently used entry once over capacity. - if (this.entries.Count > this.Capacity) - { - LinkedListNode last = this.usage.Last!; - this.usage.RemoveLast(); - _ = this.entries.Remove(last.Value.Key); + // Evict the least recently used entry once over capacity. + if (this.entries.Count > this.Capacity) + { + LinkedListNode last = this.usage.Last!; + this.usage.RemoveLast(); + _ = this.entries.Remove(last.Value.Key); + } } - - return value; } /// @@ -197,17 +252,20 @@ internal bool TryGetValue(RichTextGlyphRenderer.CacheKey key, [NotNullWhen(true) /// internal bool TryGetRunPath(RunPathCacheKey key, [NotNullWhen(true)] out IPath? path) { - if (!this.runPathEntries.TryGetValue(key, out LinkedListNode? node)) + lock (this.sync) { - path = null; - return false; - } + if (!this.runPathEntries.TryGetValue(key, out LinkedListNode? node)) + { + path = null; + return false; + } - // Move the hit to the head so the least recently used entry stays at the tail. - this.runPathUsage.Remove(node); - this.runPathUsage.AddFirst(node); - path = node.Value.Path; - return true; + // Move the hit to the head so the least recently used entry stays at the tail. + this.runPathUsage.Remove(node); + this.runPathUsage.AddFirst(node); + path = node.Value.Path; + return true; + } } /// @@ -217,16 +275,30 @@ internal bool TryGetRunPath(RunPathCacheKey key, [NotNullWhen(true)] out IPath? /// The positioned path to cache. internal void AddRunPath(RunPathCacheKey key, IPath path) { - LinkedListNode node = new(new RunPathEntry(key, path)); - this.runPathUsage.AddFirst(node); - this.runPathEntries.Add(key, node); + // Bounds are lazily stored as a nullable struct by paths. Initialize them before + // sharing the path so concurrent command creation never races that first write. + _ = path.Bounds; - // Evict the least recently used entry once over capacity. - if (this.runPathEntries.Count > this.runPathCapacity) + lock (this.sync) { - LinkedListNode last = this.runPathUsage.Last!; - this.runPathUsage.RemoveLast(); - _ = this.runPathEntries.Remove(last.Value.Key); + // Positioned paths are built outside the lock, so concurrent misses may publish + // the same key. Keep the first completed path in the cache. + if (this.runPathEntries.ContainsKey(key)) + { + return; + } + + LinkedListNode node = new(new RunPathEntry(key, path)); + this.runPathUsage.AddFirst(node); + this.runPathEntries.Add(key, node); + + // Evict the least recently used entry once over capacity. + if (this.runPathEntries.Count > this.runPathCapacity) + { + LinkedListNode last = this.runPathUsage.Last!; + this.runPathUsage.RemoveLast(); + _ = this.runPathEntries.Remove(last.Value.Key); + } } } @@ -493,4 +565,25 @@ public RunPathEntry(RunPathCacheKey key, IPath path) /// public IPath Path { get; } } + + /// + /// Working buffers leased to one renderer through operation submission and disposal. + /// + internal sealed class DrawingScratch + { + /// + /// Gets the drawing operations emitted by this renderer. + /// + public List Operations { get; } = []; + + /// + /// Gets the render-pass index buffer, avoiding sorting full operation structs. + /// + public List<(byte RenderPass, int Sequence)> SortBuffer { get; } = []; + + /// + /// Gets the stack pairing nested text composite layer commands. + /// + public List CompositeLayers { get; } = []; + } } diff --git a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs index 3073a590..c7b17e7a 100644 --- a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs +++ b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.cs @@ -188,12 +188,17 @@ internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder private CacheKey currentCacheKey; /// - /// The cache entries for the current glyph key. Assigned on a cache hit and only read on - /// hit paths; the miss path appends through the cache-owned list instead, so no per-glyph - /// list is allocated here. + /// Completed cache entries for the current glyph. Published lists are never modified, + /// so a renderer can consume them outside the cache lock, even after eviction or Clear. /// private List? currentCacheEntries; + /// + /// Entries being built for a cache miss. The list transfers to the cache only after the + /// entire glyph has completed, so other draws cannot see an incomplete layer sequence. + /// + private List? pendingCacheEntries; + /// /// The transformed (post-) bounding-box location /// of the current glyph. Stored so can compute @@ -209,25 +214,18 @@ internal sealed partial class RichTextGlyphRenderer : BaseGlyphBuilder /// Default pen for outlined text, or for fill-only. /// Default brush for filled text, or for outline-only. /// Caller-owned glyph cache shared across renderer instances. - /// - /// The caller-owned operation list this renderer emits into. Passing the canvas's reusable - /// list keeps its capacity across draws instead of regrowing a fresh list per call; it is - /// cleared in and must not be shared by concurrently live renderers. - /// public RichTextGlyphRenderer( DrawingOptions drawingOptions, IPath? path, Pen? pen, Brush? brush, - DrawingTextCache glyphCache, - List operations) + DrawingTextCache glyphCache) : base(drawingOptions.Transform) { this.drawingOptions = drawingOptions; this.defaultPen = pen; this.defaultBrush = brush; this.glyphCache = glyphCache; - this.DrawingOperations = operations; this.currentCompositionMode = drawingOptions.GraphicsOptions.AlphaCompositionMode; this.currentBlendingMode = drawingOptions.GraphicsOptions.ColorBlendingMode; @@ -254,6 +252,8 @@ public RichTextGlyphRenderer( this.rasterizationRequired = true; this.noCache = true; } + + this.Scratch = glyphCache.RentScratch(); } /// @@ -261,7 +261,12 @@ public RichTextGlyphRenderer( /// After RenderText completes, this list is consumed by /// to build composition commands. /// - public List DrawingOperations { get; } + public List DrawingOperations => this.Scratch.Operations; + + /// + /// Gets the exclusive working buffers retained until this renderer is disposed. + /// + public DrawingTextCache.DrawingScratch Scratch { get; } /// /// Gets a value indicating whether per-grapheme glyph collections are aggregated. @@ -660,6 +665,7 @@ protected override void EndGlyph() { // The layer has already been rendered. this.hasLayer = false; + this.PublishGlyph(); return; } @@ -713,6 +719,7 @@ protected override void EndGlyph() this.UpdateCache(renderData); } + this.PublishGlyph(); return; } @@ -792,6 +799,8 @@ protected override void EndGlyph() GlyphClip = this.currentGlyphClip }); } + + this.PublishGlyph(); } /// @@ -1029,8 +1038,8 @@ private void RecordMarker(GlyphRenderData entry) } /// - /// Stores a entry in the glyph cache under the - /// current key. Creates the cache list on first insertion for a given key. Every 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. /// @@ -1038,7 +1047,30 @@ private void RecordMarker(GlyphRenderData entry) private void UpdateCache(GlyphRenderData renderData) { renderData.SourceOrigin = this.currentTransformedBoundsLocation; - this.glyphCache.GetOrAdd(this.currentCacheKey).Add(renderData); + + // Path bounds use a lazy nullable-struct field. Materialize it while the translated + // path is still private; later canvases may read these bounds concurrently. + if (renderData.FillPath is not null) + { + _ = renderData.FillPath.Bounds; + } + + this.pendingCacheEntries ??= []; + this.pendingCacheEntries.Add(renderData); + } + + /// + /// Publishes a successfully completed glyph without copying its entries. + /// + private void PublishGlyph() + { + // Cache hits and non-cacheable glyphs have no pending list. On a miss, transfer + // ownership once, after all layers and group markers have been recorded. + if (this.pendingCacheEntries is not null) + { + this.glyphCache.Add(this.currentCacheKey, this.pendingCacheEntries); + this.pendingCacheEntries = null; + } } /// @@ -1058,8 +1090,10 @@ protected override void Dispose(bool disposing) this.isDisposed = true; if (disposing) { - // The glyph cache is owned outside this renderer and outlives this draw call. - this.DrawingOperations.Clear(); + // Return all buffers even if layout or command submission threw. A partial glyph + // remains private and is discarded rather than being published by cleanup. + this.pendingCacheEntries = null; + this.glyphCache.ReturnScratch(this.Scratch); } base.Dispose(disposing); diff --git a/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs new file mode 100644 index 00000000..8cc12e8a --- /dev/null +++ b/tests/ImageSharp.Drawing.Tests/Processing/DrawingTextCacheTests.cs @@ -0,0 +1,334 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using Moq; +using SixLabors.Fonts; +using SixLabors.Fonts.Rendering; +using SixLabors.Fonts.Unicode; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Drawing.Processing.Processors.Text; +using SixLabors.ImageSharp.Drawing.Tests.TestUtilities.ImageComparison; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Drawing.Tests.Processing; + +public class DrawingTextCacheTests +{ + /// + /// Verifies exclusive buffer ownership across overlapping draws, clearing, and disposal. + /// + [Fact] + public void OverlappingRenderers_ClearAndDisposePreserveOtherDraw() + { + Font font = TestFontUtilities.GetFont(TestFonts.OpenSans, 24); + DrawingTextCache cache = new(); + using RichTextGlyphRenderer first = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Red), cache); + TextRenderer.RenderTo(first, "Hello", new RichTextOptions(font)); + DrawingOperation[] expected = first.DrawingOperations.ToArray(); + + using (RichTextGlyphRenderer second = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Blue), cache)) + { + // Hold both renderers live to test exclusive leases deterministically, without + // depending on the scheduler to overlap the two operation lists. + TextRenderer.RenderTo(second, "World", new RichTextOptions(font)); + cache.Clear(); + Assert.Equal(0, cache.Count); + Assert.NotEmpty(second.DrawingOperations); + Assert.NotSame(first.Scratch, second.Scratch); + } + + Assert.NotEmpty(expected); + Assert.Equal(expected, first.DrawingOperations); + + DrawingTextCache.DrawingScratch returned; + using (RichTextGlyphRenderer next = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Black), cache)) + { + returned = next.Scratch; + Assert.Empty(next.DrawingOperations); + Assert.NotSame(first.Scratch, returned); + TextRenderer.RenderTo(next, "H", new RichTextOptions(font)); + } + + using RichTextGlyphRenderer reused = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Black), cache); + Assert.Same(returned, reused.Scratch); + Assert.Empty(reused.DrawingOperations); + Assert.Equal(expected, first.DrawingOperations); + } + + /// + /// Verifies complete publication and abandonment of layered glyphs. + /// + /// Whether to finish the glyph before disposing the renderer. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LayeredGlyph_PublishesOnlyAfterCompletion(bool completeGlyph) + { + Font font = TestFontUtilities.GetFont(TestFonts.OpenSans, 24); + RichTextOptions options = new(font); + FontRectangle bounds = default; + GlyphRendererParameters parameters = default; + Mock capture = new(); + capture.Setup(x => x.BeginGlyph(in It.Ref.IsAny, in It.Ref.IsAny)) + .Callback(new InvocationAction(invocation => + { + bounds = (FontRectangle)invocation.Arguments[0]; + parameters = (GlyphRendererParameters)invocation.Arguments[1]; + })) + .Returns(false); + + // Obtain valid callback parameters through the font renderer, then stop between + // layers deterministically rather than relying on a scheduler to expose the race. + TextRenderer.RenderTo(capture.Object, "H", options); + capture.Verify(x => x.BeginGlyph(in It.Ref.IsAny, in It.Ref.IsAny), Times.Once); + DrawingTextCache cache = new(); + using (RichTextGlyphRenderer builder = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Red), cache)) + { + IGlyphRenderer callbacks = builder; + callbacks.BeginText(bounds); + Assert.True(callbacks.BeginGlyph(bounds, parameters)); + for (int layer = 0; layer < 2; layer++) + { + callbacks.BeginLayer(null, FillRule.NonZero); + callbacks.BeginFigure(); + callbacks.MoveTo(new Vector2(layer * 20, 0)); + callbacks.LineTo(new Vector2((layer * 20) + 10, 0)); + callbacks.LineTo(new Vector2((layer * 20) + 10, 10)); + callbacks.LineTo(new Vector2(layer * 20, 10)); + callbacks.EndFigure(); + callbacks.EndLayer(); + Assert.Equal(0, cache.Count); + } + + Assert.Equal(2, builder.DrawingOperations.Count); + if (completeGlyph) + { + callbacks.EndGlyph(); + callbacks.EndText(); + Assert.Equal(1, cache.Count); + using RichTextGlyphRenderer reader = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Blue), cache); + TextRenderer.RenderTo(reader, "H", options); + Assert.Equal(2, reader.DrawingOperations.Count); + for (int layer = 0; layer < 2; layer++) + { + Assert.Same(builder.DrawingOperations[layer].Path, reader.DrawingOperations[layer].Path); + } + } + } + + // Abandoning a draw must discard its incomplete glyph, not publish it from Dispose. + Assert.Equal(completeGlyph ? 1 : 0, cache.Count); + } + + /// + /// Verifies duplicate publication preserves the first complete entry and eviction preserves acquired data. + /// + [Fact] + public void Add_DuplicateCompletedGlyphPreservesFirstEntry() + { + DrawingTextCache cache = new(1); + RichTextGlyphRenderer.CacheKey key = new() { Font = "test", GlyphId = 1 }; + List first = [new() { FillPath = new RectanglePolygon(0, 0, 10, 10) }]; + List second = [new() { FillPath = new RectanglePolygon(0, 0, 20, 20) }]; + + // Two misses can finish the same glyph independently. Publishing the second must + // neither append layers to the first nor replace the result another draw is reading. + cache.Add(key, first); + cache.Add(key, second); + Assert.True(cache.TryGetValue(key, out List actual)); + Assert.Same(first, actual); + Assert.Single(actual); + Assert.Equal(1, cache.Count); + + cache.Add(new RichTextGlyphRenderer.CacheKey { Font = "test", GlyphId = 2 }, second); + Assert.False(cache.TryGetValue(key, out _)); + Assert.Equal(1, cache.Count); + Assert.Single(actual); + } + + /// + /// Verifies concurrent drawing matches serial reuse of the same outlines at varied positions. + /// + [Fact] + public async Task ConcurrentCanvases_MatchSerialOutputWithSameCachedOutlines() + { + const int workers = 4; + Font font = TestFontUtilities.GetFont(TestFonts.OpenSans, 24); + Font emojiFont = TestFontUtilities.GetFont(TestFonts.NotoColorEmojiRegular, 48); + Assert.True(font.FontMetrics.TryGetGlyphMetrics( + new CodePoint('H'), + TextAttributes.None, + TextDecorations.None, + LayoutMode.HorizontalTopBottom, + ColorFontSupport.None, + null, + out FontGlyphMetrics metrics)); + + DrawingTextCache shared = new(); + DrawingTextCache serial = new(); + + // Both caches must contain outlines built at the same positions. The assertion + // compares concurrent and serial reuse, not reuse against newly built geometry. + for (int index = 0; index < workers; index++) + { + using Image warmShared = new(320, 160); + using Image warmSerial = new(320, 160); + DrawSample(warmShared, shared, font, emojiFont, metrics.GlyphId, index); + DrawSample(warmSerial, serial, font, emojiFont, metrics.GlyphId, index); + } + + using Barrier start = new(workers); + Task[] tasks = new Task[workers]; + for (int worker = 0; worker < workers; worker++) + { + int index = worker; + // Barrier participants need dedicated threads so waiting for their peers does + // not starve thread-pool work scheduled by other tests on small CI runners. + tasks[worker] = Task.Factory.StartNew(() => + { + using Image expected = new(320, 160); + lock (serial) + { + DrawSample(expected, serial, font, emojiFont, metrics.GlyphId, index); + } + + Assert.True(start.SignalAndWait(TimeSpan.FromSeconds(30))); + + for (int iteration = 0; iteration < 12; iteration++) + { + using Image actual = new(320, 160); + DrawSample(actual, shared, font, emojiFont, metrics.GlyphId, index); + ImageComparer.Exact.VerifySimilarity(expected, actual); + Assert.Equal(serial.Count, shared.Count); + } + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + await Task.WhenAll(tasks); + } + + /// + /// Verifies competing publications and cache removal preserve acquired glyph and run data. + /// + /// The glyph cache capacity. + /// Whether competing writers also clear the cache. + [Theory] + [InlineData(2, false)] + [InlineData(DrawingTextCache.DefaultCapacity, false)] + [InlineData(DrawingTextCache.DefaultCapacity, true)] + public async Task ConcurrentPublication_EvictionAndClearPreserveAcquiredEntries(int capacity, bool clearDuringDrawing) + { + const int workers = 4; + DrawingTextCache cache = new(capacity); + using Barrier phase = new(workers); + Task[] tasks = new Task[workers]; + for (int worker = 0; worker < workers; worker++) + { + int index = worker; + // Barrier participants need dedicated threads so waiting for their peers does + // not starve thread-pool work scheduled by other tests on small CI runners. + tasks[worker] = Task.Factory.StartNew(() => + { + IPath first = new RectanglePolygon(index, 0, 10, 10); + IPath second = new RectanglePolygon(index, 10, 10, 10); + List entries = + [ + new() { FillPath = first }, + new() { FillPath = second } + ]; + + for (int iteration = 0; iteration < 32; iteration++) + { + RichTextGlyphRenderer.CacheKey key = new() { Font = "test", GlyphId = (ushort)iteration }; + DrawingTextCache.RunPathCacheKey runKey = new( + [new DrawingTextCache.RunPathCacheEntry(first, Vector2.Zero, key, true)], 1); + + // Race complete publications of the same key. No reader may see a + // mixture of entries from different producers or an incomplete list. + Assert.True(phase.SignalAndWait(TimeSpan.FromSeconds(30))); + cache.Add(key, entries); + cache.AddRunPath(runKey, first); + Assert.True(phase.SignalAndWait(TimeSpan.FromSeconds(30))); + Assert.True(cache.TryGetValue(key, out List acquired)); + Assert.True(cache.TryGetRunPath(runKey, out IPath acquiredRun)); + Assert.Equal(2, acquired.Count); + IPath acquiredFirst = acquired[0].FillPath; + IPath acquiredSecond = acquired[1].FillPath; + Assert.Equal(new RectangleF(acquiredFirst.Bounds.X, 0, 10, 10), acquiredFirst.Bounds); + Assert.Equal(new RectangleF(acquiredFirst.Bounds.X, 10, 10, 10), acquiredSecond.Bounds); + RectangleF runBounds = acquiredRun.Bounds; + Assert.Equal(new SizeF(10, 10), runBounds.Size); + Assert.True(phase.SignalAndWait(TimeSpan.FromSeconds(30))); + + // Every reader now owns a result. Competing insertions force eviction + // at small capacity; Clear additionally removes both indexes at once. + RichTextGlyphRenderer.CacheKey competingKey = new() + { + Font = "test", + GlyphId = (ushort)(100 + (iteration * workers) + index) + }; + + cache.Add(competingKey, entries); + cache.AddRunPath(new DrawingTextCache.RunPathCacheKey( + [new DrawingTextCache.RunPathCacheEntry(first, Vector2.Zero, competingKey, true)], 1), first); + + if (clearDuringDrawing) + { + cache.Clear(); + } + + Assert.InRange(cache.Count, 0, capacity); + Assert.True(phase.SignalAndWait(TimeSpan.FromSeconds(30))); + Assert.Equal(2, acquired.Count); + Assert.Same(acquiredFirst, acquired[0].FillPath); + Assert.Same(acquiredSecond, acquired[1].FillPath); + Assert.Equal(runBounds, acquiredRun.Bounds); + } + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + await Task.WhenAll(tasks); + } + + /// + /// Draws ordinary and decorated text, a positioned glyph run, and nested color layers. + /// + /// The independent target for this draw. + /// The cache shared by concurrent canvases or used for the serial reference. + /// The ordinary text font. + /// The layered color font. + /// The ordinary font's H glyph. + /// The worker index, varying placement and paint between canvases. + private static void DrawSample(Image image, DrawingTextCache cache, Font font, Font emojiFont, ushort glyphId, int index) + { + using DrawingCanvas canvas = image.Frames.RootFrame.CreateCanvas(image.Configuration, new DrawingOptions(), cache); + Brush brush = Brushes.Solid(index % 2 == 0 ? Color.Red : Color.Blue); + + RichTextOptions textOptions = new(font) + { + Origin = new Vector2(8 + index, 8), + TextRuns = [new RichTextRun { Start = 0, End = 5, TextDecorations = TextDecorations.Underline }] + }; + + canvas.DrawText(textOptions, "Hello World", brush, null); + RichGlyphOptions glyphOptions = new() { Font = font }; + canvas.DrawText( + [glyphId, glyphId, glyphId], + [new Vector2(8 + index, 72), new Vector2(32 + index, 72), new Vector2(56 + index, 72)], + glyphOptions, + brush, + null); + + RichGlyphOptions emojiOptions = new() + { + Font = emojiFont, + Origin = new Vector2(100 + index, 80), + ColorFontSupport = ColorFontSupport.ColrV1 + }; + + // This glyph contains nested SoftLight/SrcIn composite groups, exercising layer-stack + // isolation and complete publication of the cached layer and group-marker sequence. + canvas.DrawText(2629, emojiOptions, brush, null); + } +} diff --git a/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs index 86a137eb..54f7554b 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs @@ -14,6 +14,34 @@ namespace SixLabors.ImageSharp.Drawing.Tests.Processing; public class RichTextGlyphRendererTests { + /// + /// Verifies moved text retains cached outline identity and updates its destination. + /// + [Fact] + public void MovedText_CacheHitReusesOutline() + { + Font font = TestFontUtilities.GetFont(TestFonts.OpenSans, 24); + DrawingTextCache cache = new(); + using RichTextGlyphRenderer first = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Red), cache); + TextRenderer.RenderTo(first, "Hello World", new RichTextOptions(font) { Origin = new Vector2(11, 8) }); + using RichTextGlyphRenderer cached = new(new DrawingOptions(), null, null, Brushes.Solid(Color.Red), cache); + TextRenderer.RenderTo(cached, "Hello World", new RichTextOptions(font) { Origin = new Vector2(8, 8) }); + + Assert.NotEmpty(first.DrawingOperations); + Assert.Equal(first.DrawingOperations.Count, cached.DrawingOperations.Count); + for (int i = 0; i < first.DrawingOperations.Count; i++) + { + DrawingOperation expected = first.DrawingOperations[i]; + DrawingOperation actual = cached.DrawingOperations[i]; + + // Moving text must reuse the vector outline while moving its destination. + // Rebuilding an outline at the new origin is not the reference for a cache hit. + Assert.Same(expected.Path, actual.Path); + Assert.Equal(expected.RenderLocation.X - 3, actual.RenderLocation.X); + Assert.Equal(expected.RenderLocation.Y, actual.RenderLocation.Y); + } + } + [Fact] public void SetDecoration_ContiguousRun_EmitsSingleDecorationOperation() { @@ -86,14 +114,14 @@ public void RenderGlyph_NestedColrV1Composite_EmitsIsolatedGroups() ColorFontSupport = ColorFontSupport.ColrV1 }; - List operations = []; using RichTextGlyphRenderer renderer = new( new DrawingOptions(), path: null, pen: null, brush: Brushes.Solid(Color.Black), - new DrawingTextCache(), - operations); + new DrawingTextCache()); + + List operations = renderer.DrawingOperations; // This glyph is a SoftLight composite whose source is a nested SrcIn composite, and // the inner source is a linear gradient with no outline. Each composite lowers to one @@ -192,24 +220,24 @@ public void RenderGlyph_LayeredColrV1CacheHit_ReplaysFreshOperationSequence() }; DrawingTextCache cache = new(); - List fresh = []; using RichTextGlyphRenderer freshRenderer = new( new DrawingOptions(), path: null, pen: null, brush: Brushes.Solid(Color.Black), - cache, - fresh); + cache); + + List fresh = freshRenderer.DrawingOperations; TextRenderer.RenderTo(freshRenderer, 2629, glyphOptions); - List cached = []; using RichTextGlyphRenderer cachedRenderer = new( new DrawingOptions(), path: null, pen: null, brush: Brushes.Solid(Color.Black), - cache, - cached); + cache); + + List cached = cachedRenderer.DrawingOperations; TextRenderer.RenderTo(cachedRenderer, 2629, glyphOptions); Assert.NotEmpty(fresh); @@ -287,18 +315,18 @@ private static int CountOperations(Font font, string text, List? ru options.TextRuns = [.. runs]; } - List operations = []; using RichTextGlyphRenderer renderer = new( new DrawingOptions(), path: null, pen: null, brush: Brushes.Solid(Color.Black), - new DrawingTextCache(), - operations); + new DrawingTextCache()); + + List operations = renderer.DrawingOperations; TextRenderer.RenderTo(renderer, text, options); - // Dispose clears the caller-owned operation list, so count before leaving scope. + // Dispose clears the leased operation list, so count before leaving scope. return operations.Count; } @@ -310,18 +338,18 @@ private static DrawingOperation RenderSingleGlyph(Font font, PointF origin, Draw Origin = origin, }; - List operations = []; using RichTextGlyphRenderer renderer = new( new DrawingOptions(), path: null, pen: null, brush: Brushes.Solid(Color.Black), - cache, - operations); + cache); + + List operations = renderer.DrawingOperations; TextRenderer.RenderTo(renderer, "H", options); - // Return the value copy before disposing the renderer, which clears the caller-owned list. + // Return the value copy before disposing the renderer, which clears the leased list. return Assert.Single(operations); } }