// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System; using System.Collections.Generic; using System.Numerics; using SixLabors.Fonts; using SixLabors.Fonts.Rendering; using SixLabors.ImageSharp.Drawing.Processing.Backends; using SixLabors.ImageSharp.Drawing.Processing.Processors.Text; using SixLabors.ImageSharp.Drawing.Text; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace SixLabors.ImageSharp.Drawing.Processing { /// /// A drawing canvas over a frame target. /// /// The pixel format. public sealed class DrawingCanvas : DrawingCanvas where TPixel : unmanaged, IPixel { /// /// Processing configuration used by operations executed through this canvas. /// private readonly Configuration configuration; /// /// Backend responsible for rasterizing and composing draw commands. /// private readonly IDrawingBackend backend; /// /// Destination frame receiving rendered output. /// private readonly ICanvasFrame targetFrame; /// /// Command batcher used to defer and submit composition commands. /// private readonly DrawingCanvasBatcher batcher; /// /// Temporary image resources that must stay alive until queued commands are flushed. /// private readonly List> pendingImageResources = []; /// /// Indicates whether this canvas owns final disposal of the shared batcher. /// private readonly bool ownsBatcher; /// /// Tracks whether this instance has already been disposed. /// private bool isDisposed; /// /// Stack of saved drawing states for Save/Restore operations. /// private readonly Stack savedStates = new(); // Per-canvas glyph-outline cache: hoists RichTextGlyphRenderer's per-glyph outline cache from // per-DrawText-call scope up to the whole canvas, so a glyph outline built once is reused by // every DrawText call on this canvas (across a frame's many text runs) instead of being // rebuilt for every run on a text-heavy page. private readonly Dictionary> glyphCache = []; /// /// Initializes a new instance of the class. /// /// The active processing configuration. /// Initial drawing options for this canvas instance. /// The destination target region. /// Initial clip paths for this canvas instance. public DrawingCanvas( Configuration configuration, DrawingOptions options, Buffer2DRegion targetRegion, params IPath[] clipPaths) : this(configuration, options, new MemoryCanvasFrame(targetRegion), clipPaths) { } /// /// Initializes a new instance of the class. /// /// The active processing configuration. /// Initial drawing options for this canvas instance. /// The destination frame. /// Initial clip paths for this canvas instance. public DrawingCanvas( Configuration configuration, DrawingOptions options, ICanvasFrame targetFrame, params IPath[] clipPaths) : this(configuration, options, configuration.GetDrawingBackend(), targetFrame, clipPaths) { } /// /// Initializes a new instance of the class with an explicit backend and initial state. /// /// The active processing configuration. /// Initial drawing options for this canvas instance. /// The drawing backend implementation. /// The destination frame. /// Initial clip paths for this canvas instance. public DrawingCanvas( Configuration configuration, DrawingOptions options, IDrawingBackend backend, ICanvasFrame targetFrame, params IPath[] clipPaths) : this( configuration, backend, targetFrame, new DrawingCanvasBatcher(configuration), new DrawingCanvasState(options, clipPaths, targetFrame.Bounds, targetFrame.Bounds.Location), true) { } /// /// Initializes a new instance of the class /// with explicit backend and batcher instances. /// /// The active processing configuration. /// The drawing backend implementation. /// The destination frame. /// The command batcher used for deferred composition. /// The default state used when no scoped state is active. /// Whether this canvas owns final disposal of the shared batcher. private DrawingCanvas( Configuration configuration, IDrawingBackend backend, ICanvasFrame targetFrame, DrawingCanvasBatcher batcher, DrawingCanvasState defaultState, bool ownsBatcher) { Guard.NotNull(configuration, nameof(configuration)); Guard.NotNull(backend, nameof(backend)); Guard.NotNull(targetFrame, nameof(targetFrame)); Guard.NotNull(batcher, nameof(batcher)); Guard.NotNull(defaultState, nameof(defaultState)); if (!targetFrame.TryGetCpuRegion(out _) && !targetFrame.TryGetNativeSurface(out _)) { throw new NotSupportedException("Canvas frame must expose either a CPU region or a native surface."); } this.configuration = configuration; this.backend = backend; this.targetFrame = targetFrame; this.batcher = batcher; this.ownsBatcher = ownsBatcher; // Canvas coordinates are local to the current frame; origin stays at (0,0). this.Bounds = new Rectangle(0, 0, targetFrame.Bounds.Width, targetFrame.Bounds.Height); this.savedStates.Push(defaultState); } /// public override Rectangle Bounds { get; } /// public override int SaveCount => this.savedStates.Count; /// public override int Save() { this.EnsureNotDisposed(); DrawingCanvasState current = this.ResolveState(); // Push a non-layer copy of the current state. // Only states pushed by SaveLayer() should trigger layer compositing on restore. this.savedStates.Push(new DrawingCanvasState(current.Options, current.ClipPaths, current.TargetBounds, current.DestinationOffset)); return this.savedStates.Count; } /// public override int Save(DrawingOptions options, params IPath[] clipPaths) => this.SaveCore(options, clipPaths); private int SaveCore(DrawingOptions options, IReadOnlyList clipPaths) { this.EnsureNotDisposed(); Guard.NotNull(options, nameof(options)); Guard.NotNull(clipPaths, nameof(clipPaths)); _ = this.Save(); DrawingCanvasState current = this.ResolveState(); DrawingCanvasState state = new(options, clipPaths, current.TargetBounds, current.DestinationOffset); _ = this.savedStates.Pop(); this.savedStates.Push(state); return this.savedStates.Count; } /// public override int SaveLayer(GraphicsOptions layerOptions, Rectangle bounds) { this.EnsureNotDisposed(); Guard.NotNull(layerOptions, nameof(layerOptions)); Guard.MustBeGreaterThan(bounds.Width, 0, nameof(bounds)); Guard.MustBeGreaterThan(bounds.Height, 0, nameof(bounds)); DrawingCanvasState currentState = this.ResolveState(); Rectangle absoluteLayerBounds = ResolveLayerBounds(currentState, bounds); // Keep layer boundaries in the shared command stream so the backend can lower them inline. this.batcher.AddComposition(CompositionCommand.CreateBeginLayer(absoluteLayerBounds, layerOptions)); // A bounded layer clips and allocates the isolated target, but it does not shift the canvas coordinate system. DrawingCanvasState layerState = new(currentState.Options, currentState.ClipPaths, absoluteLayerBounds, currentState.DestinationOffset) { IsLayer = true, LayerOptions = layerOptions, }; this.savedStates.Push(layerState); return this.savedStates.Count; } /// public override void Restore() { this.EnsureNotDisposed(); if (this.savedStates.Count <= 1) { return; } DrawingCanvasState popped = this.savedStates.Pop(); if (popped.IsLayer) { this.batcher.AddComposition(CompositionCommand.CreateEndLayer(popped.TargetBounds, popped.LayerOptions!)); } } /// public override void RestoreTo(int saveCount) { this.EnsureNotDisposed(); Guard.MustBeBetweenOrEqualTo(saveCount, 1, this.savedStates.Count, nameof(saveCount)); this.RestoreToCore(saveCount); } /// public override DrawingCanvas CreateRegion(Rectangle region) { this.EnsureNotDisposed(); Rectangle clipped = Rectangle.Intersect(this.Bounds, region); CanvasRegionFrame childFrame = new(this.targetFrame, clipped); DrawingCanvasState currentState = this.ResolveState(); // Regions share the same batcher and deferred image resources. Only the root canvas owns flushing. return new DrawingCanvas( this.configuration, this.backend, childFrame, this.batcher, new DrawingCanvasState(currentState.Options, currentState.ClipPaths, childFrame.Bounds, childFrame.Bounds.Location) { IsLayer = currentState.IsLayer, LayerOptions = currentState.LayerOptions, }, false); } /// public override void Clear(Brush brush, IPath path) { DrawingCanvasState state = this.ResolveState(); DrawingOptions options = state.Options.CloneForClearOperation(); this.ExecuteWithTemporaryState(options, state.ClipPaths, () => this.Fill(brush, path)); } /// public override void Fill(Brush brush, IPath path) { this.EnsureNotDisposed(); Guard.NotNull(path, nameof(path)); Guard.NotNull(brush, nameof(brush)); this.EnqueueFillPath(brush, path); } /// public override void Apply(Rectangle region, Action operation) => this.Apply(new RectanglePolygon(region), operation); /// public override void Apply(PathBuilder pathBuilder, Action operation) { Guard.NotNull(pathBuilder, nameof(pathBuilder)); this.Apply(pathBuilder.Build(), operation); } /// public override void Apply(IPath path, Action operation) { this.EnsureNotDisposed(); Guard.NotNull(path, nameof(path)); Guard.NotNull(operation, nameof(operation)); DrawingCanvasState state = this.ResolveState(); ApplyBarrier barrier = new( path.AsClosedPath(), state.Options, state.ClipPaths, this.Bounds, state.TargetBounds, state.DestinationOffset, state.IsLayer, operation); this.batcher.AddApplyBarrier(barrier); } /// /// Draws a two-point line segment using the provided pen and drawing options. /// /// Pen used to generate the line outline. /// Line start point. /// Line end point. public void DrawLine(Pen pen, PointF start, PointF end) { this.EnsureNotDisposed(); Guard.NotNull(pen, nameof(pen)); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; // Stroke geometry can self-overlap; non-zero winding preserves stroke semantics. if (effectiveOptions.ShapeOptions.IntersectionRule != IntersectionRule.NonZero) { ShapeOptions shapeOptions = effectiveOptions.ShapeOptions.DeepClone(); shapeOptions.IntersectionRule = IntersectionRule.NonZero; effectiveOptions = new DrawingOptions(effectiveOptions.GraphicsOptions, shapeOptions, effectiveOptions.Transform); } if (state.ClipPaths.Count > 0 || !pen.StrokePattern.IsEmpty) { this.PrepareCompositionCore( new Path([start, end]), pen.StrokeFill, effectiveOptions, RasterizerSamplingOrigin.PixelCenter, state.ClipPaths, pen); return; } this.PrepareStrokeLineSegmentCompositionCore(start, end, pen.StrokeFill, effectiveOptions, pen); } /// public override void DrawLine(Pen pen, params PointF[] points) { Guard.NotNull(points, nameof(points)); if (points.Length == 2) { this.DrawLine(pen, points[0], points[1]); return; } this.EnsureNotDisposed(); Guard.NotNull(pen, nameof(pen)); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; // Stroke geometry can self-overlap; non-zero winding preserves stroke semantics. if (effectiveOptions.ShapeOptions.IntersectionRule != IntersectionRule.NonZero) { ShapeOptions shapeOptions = effectiveOptions.ShapeOptions.DeepClone(); shapeOptions.IntersectionRule = IntersectionRule.NonZero; effectiveOptions = new DrawingOptions(effectiveOptions.GraphicsOptions, shapeOptions, effectiveOptions.Transform); } if (state.ClipPaths.Count > 0 || !pen.StrokePattern.IsEmpty) { this.PrepareCompositionCore( new Path(points), pen.StrokeFill, effectiveOptions, RasterizerSamplingOrigin.PixelCenter, state.ClipPaths, pen); return; } this.PrepareStrokePolylineCompositionCore(points, pen.StrokeFill, effectiveOptions, pen); } /// public override void Draw(Pen pen, IPath path) { this.EnsureNotDisposed(); Guard.NotNull(pen, nameof(pen)); Guard.NotNull(path, nameof(path)); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; // Stroke geometry can self-overlap; non-zero winding preserves stroke semantics. if (effectiveOptions.ShapeOptions.IntersectionRule != IntersectionRule.NonZero) { ShapeOptions shapeOptions = effectiveOptions.ShapeOptions.DeepClone(); shapeOptions.IntersectionRule = IntersectionRule.NonZero; effectiveOptions = new DrawingOptions(effectiveOptions.GraphicsOptions, shapeOptions, effectiveOptions.Transform); } this.PrepareCompositionCore( path, pen.StrokeFill, effectiveOptions, RasterizerSamplingOrigin.PixelCenter, state.ClipPaths, pen); } /// public override void DrawText( RichTextOptions textOptions, ReadOnlySpan text, Brush? brush, Pen? pen) => this.DrawTextCore(textOptions, text, path: null, brush, pen); /// public override void DrawText( RichTextOptions textOptions, ReadOnlySpan text, IPath path, Brush? brush, Pen? pen) { Guard.NotNull(path, nameof(path)); this.DrawTextCore(textOptions, text, path, brush, pen); } private void DrawTextCore( RichTextOptions textOptions, ReadOnlySpan text, IPath? path, Brush? brush, Pen? pen) { this.EnsureNotDisposed(); if (text.IsEmpty) { return; } DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; EnsureTextPaint(brush, pen); RichTextOptions configuredOptions = ConfigureTextOptions(textOptions, path, out IPath? configuredPath); using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, configuredPath, pen, brush, this.glyphCache); TextRenderer renderer = new(glyphRenderer); renderer.RenderText(text, configuredOptions); this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions, state.ClipPaths); } /// public override void DrawText( TextBlock textBlock, PointF location, float wrappingLength, Brush? brush, Pen? pen) { this.EnsureNotDisposed(); Guard.NotNull(textBlock, nameof(textBlock)); EnsureTextPaint(brush, pen); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; // Prepared text already owns shaping and layout options. The caller-supplied // location is therefore applied as canvas placement before the active canvas // transform, instead of mutating text options or rebuilding the block. DrawingOptions placedOptions = new( effectiveOptions.GraphicsOptions, effectiveOptions.ShapeOptions, Matrix4x4.CreateTranslation(location.X, location.Y, 0) * effectiveOptions.Transform); using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.glyphCache); textBlock.RenderTo(glyphRenderer, wrappingLength); this.DrawTextOperations(glyphRenderer.DrawingOperations, placedOptions, state.ClipPaths); } /// public override void DrawText( TextBlock textBlock, IPath path, float wrappingLength, Brush? brush, Pen? pen) { this.EnsureNotDisposed(); Guard.NotNull(textBlock, nameof(textBlock)); Guard.NotNull(path, nameof(path)); EnsureTextPaint(brush, pen); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.glyphCache); textBlock.RenderTo(glyphRenderer, wrappingLength); this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions, state.ClipPaths); } /// public override void DrawText( LineLayout lineLayout, PointF location, Brush? brush, Pen? pen) { this.EnsureNotDisposed(); Guard.NotNull(lineLayout, nameof(lineLayout)); EnsureTextPaint(brush, pen); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; // LineLayout represents a single already-broken line. Placement belongs // to the drawing host, so the line can be reused in arbitrary slots // without changing the prepared text object. DrawingOptions placedOptions = new( effectiveOptions.GraphicsOptions, effectiveOptions.ShapeOptions, Matrix4x4.CreateTranslation(location.X, location.Y, 0) * effectiveOptions.Transform); using RichTextGlyphRenderer glyphRenderer = new(placedOptions, path: null, pen, brush, this.glyphCache); lineLayout.RenderTo(glyphRenderer); this.DrawTextOperations(glyphRenderer.DrawingOperations, placedOptions, state.ClipPaths); } /// public override void DrawText( LineLayout lineLayout, IPath path, Brush? brush, Pen? pen) { this.EnsureNotDisposed(); Guard.NotNull(lineLayout, nameof(lineLayout)); Guard.NotNull(path, nameof(path)); EnsureTextPaint(brush, pen); DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; using RichTextGlyphRenderer glyphRenderer = new(effectiveOptions, path, pen, brush, this.glyphCache); lineLayout.RenderTo(glyphRenderer); this.DrawTextOperations(glyphRenderer.DrawingOperations, effectiveOptions, state.ClipPaths); } /// public override void DrawGlyphs( Brush brush, Pen pen, IEnumerable glyphs) { this.EnsureNotDisposed(); Guard.NotNull(brush, nameof(brush)); Guard.NotNull(pen, nameof(pen)); Guard.NotNull(glyphs, nameof(glyphs)); DrawingCanvasState state = this.ResolveState(); DrawingOptions baseOptions = state.Options; IReadOnlyList clipPaths = state.ClipPaths; foreach (GlyphPathCollection glyph in glyphs) { if (glyph.LayerCount == 0) { continue; } if (glyph.LayerCount == 1) { this.Fill(brush, glyph.Paths); continue; } float glyphArea = glyph.Bounds.Width * glyph.Bounds.Height; for (int layerIndex = 0; layerIndex < glyph.LayerCount; layerIndex++) { GlyphLayerInfo layer = glyph.Layers[layerIndex]; if (layer.Count == 0) { continue; } PathCollection layerPaths = glyph.GetLayerPaths(layerIndex); DrawingOptions layerOptions = baseOptions.CloneOrReturnForRules( layer.IntersectionRule, layer.PixelAlphaCompositionMode, layer.PixelColorBlendingMode); bool shouldFill; if (layer.Kind is GlyphLayerKind.Decoration or GlyphLayerKind.Glyph) { shouldFill = true; } else { float layerArea = layerPaths.ComputeArea(); shouldFill = layerArea > 0F && glyphArea > 0F && (layerArea / glyphArea) < 0.50F; } this.ExecuteWithTemporaryState(layerOptions, clipPaths, () => { if (shouldFill) { this.Fill(brush, layerPaths); } else { this.Draw(pen, layerPaths); } }); } } } /// public override TextMetrics MeasureText(RichTextOptions textOptions, ReadOnlySpan text) { this.EnsureNotDisposed(); return TextMeasurer.Measure(text, textOptions); } /// public override void DrawImage( Image image, Rectangle sourceRect, RectangleF destinationRect, IResampler? sampler) { this.EnsureNotDisposed(); Guard.NotNull(image, nameof(image)); if (image is Image specificImage) { this.DrawImageCore(specificImage, sourceRect, destinationRect, sampler, ownsSourceImage: false); return; } Image convertedImage = image.CloneAs(); this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true); } /// public void DrawImage( Image image, Rectangle sourceRect, RectangleF destinationRect, IResampler? sampler = null) { this.EnsureNotDisposed(); Guard.NotNull(image, nameof(image)); this.DrawImageCore(image, sourceRect, destinationRect, sampler, ownsSourceImage: false); } /// public override DrawingBackendScene CreateScene() { this.EnsureNotDisposed(); IDisposable[]? ownedResources = this.DetachPendingImageResources(); try { return this.batcher.CreateScene(this.backend, this.targetFrame.Bounds, ownedResources); } catch { DisposeOwnedResources(ownedResources); throw; } finally { this.batcher.ClearCommandBatch(); } } /// public override void RenderScene(DrawingBackendScene scene) { this.EnsureNotDisposed(); Guard.NotNull(scene, nameof(scene)); this.batcher.AddScene(scene); } private void DrawImageCore( Image image, Rectangle sourceRect, RectangleF destinationRect, IResampler? sampler, bool ownsSourceImage) { bool disposeSourceImage = ownsSourceImage; DrawingCanvasState state = this.ResolveState(); DrawingOptions effectiveOptions = state.Options; DrawingOptions commandOptions = effectiveOptions; IReadOnlyList commandClipPaths = state.ClipPaths; if (sourceRect.Width <= 0 || sourceRect.Height <= 0 || destinationRect.Width <= 0 || destinationRect.Height <= 0) { return; } Rectangle clippedSourceRect = Rectangle.Intersect(sourceRect, image.Bounds); if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0) { return; } RectangleF clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect); if (clippedDestinationRect.Width <= 0 || clippedDestinationRect.Height <= 0) { return; } Size scaledSize = new( Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)), Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height))); bool requiresScaling = clippedSourceRect.Width != scaledSize.Width || clippedSourceRect.Height != scaledSize.Height; Image brushImage = image; RectangleF brushImageRegion = clippedSourceRect; RectangleF renderDestinationRect = clippedDestinationRect; Image? ownedImage = null; try { // Phase 1: Prepare source pixels (crop/scale) in image-local space. if (requiresScaling) { ownedImage = CreateScaledDrawImage(image, clippedSourceRect, scaledSize, sampler); brushImage = ownedImage; brushImageRegion = ownedImage.Bounds; } else if (clippedSourceRect != image.Bounds) { ownedImage = image.Clone(ctx => ctx.Crop(clippedSourceRect)); brushImage = ownedImage; brushImageRegion = ownedImage.Bounds; } // Phase 2: Apply canvas transform to image content when requested. if (effectiveOptions.Transform != Matrix4x4.Identity) { Image transformed = CreateTransformedDrawImage( brushImage, clippedDestinationRect, effectiveOptions.Transform, sampler, out renderDestinationRect); ownedImage?.Dispose(); ownedImage = transformed; brushImage = transformed; brushImageRegion = transformed.Bounds; // The image pixels and destination rect are already in transformed canvas space, // so the queued fill must not apply the canvas transform a second time. commandOptions = new DrawingOptions( effectiveOptions.GraphicsOptions, effectiveOptions.ShapeOptions, Matrix4x4.Identity); commandClipPaths = TransformClipPaths(state.ClipPaths, effectiveOptions.Transform); } if (renderDestinationRect.Width <= 0 || renderDestinationRect.Height <= 0) { return; } // Phase 3: Transfer temp-image ownership to deferred batch execution. if (!ReferenceEquals(brushImage, image)) { if (disposeSourceImage) { image.Dispose(); disposeSourceImage = false; } this.pendingImageResources.Add(brushImage); ownedImage = null; } else if (disposeSourceImage) { this.pendingImageResources.Add(image); disposeSourceImage = false; } ImageBrush brush = new(brushImage, brushImageRegion); IPath destinationPath = new RectanglePolygon( renderDestinationRect.X, renderDestinationRect.Y, renderDestinationRect.Width, renderDestinationRect.Height); this.PrepareCompositionCore( destinationPath, brush, commandOptions, RasterizerSamplingOrigin.PixelBoundary, commandClipPaths); } finally { ownedImage?.Dispose(); if (disposeSourceImage) { image.Dispose(); } } } /// /// Prepares a path fill composition command and enqueues it in the batcher. /// /// Path to fill. /// Brush used for shading. /// Effective drawing options. /// Rasterizer sampling origin. /// Optional clip paths to apply during preparation. /// Optional pen for stroke commands. private void PrepareCompositionCore( IPath path, Brush brush, DrawingOptions options, RasterizerSamplingOrigin samplingOrigin, IReadOnlyList? clipPaths = null, Pen? pen = null) { brush = this.NormalizeBrush(brush); GraphicsOptions graphicsOptions = options.GraphicsOptions; ShapeOptions shapeOptions = options.ShapeOptions; RasterizationMode rasterizationMode = graphicsOptions.Antialias ? RasterizationMode.Antialiased : RasterizationMode.Aliased; RectangleF bounds = path.Bounds; if (samplingOrigin == RasterizerSamplingOrigin.PixelCenter) { bounds = new RectangleF(bounds.X + 0.5F, bounds.Y + 0.5F, bounds.Width, bounds.Height); } Rectangle interest = Rectangle.FromLTRB( (int)MathF.Floor(bounds.Left), (int)MathF.Floor(bounds.Top), (int)MathF.Ceiling(bounds.Right), (int)MathF.Ceiling(bounds.Bottom)); RasterizerOptions rasterizerOptions = new( interest, shapeOptions.IntersectionRule, rasterizationMode, samplingOrigin, graphicsOptions.AntialiasThreshold); DrawingCanvasState state = this.ResolveState(); // Commands carry their absolute target bounds and destination origin explicitly. // Bounded layers can clip the target while preserving the active canvas coordinate origin. if (pen is null) { this.batcher.AddComposition( CompositionCommand.Create( path, brush, options, in rasterizerOptions, state.TargetBounds, state.DestinationOffset, clipPaths, state.IsLayer)); return; } this.batcher.AddStrokePath( new StrokePathCommand( path, brush, options, in rasterizerOptions, state.TargetBounds, state.DestinationOffset, pen, clipPaths, state.IsLayer)); } /// /// Enqueues one explicit two-point stroke line-segment command using the current canvas state. /// private void PrepareStrokeLineSegmentCompositionCore( PointF start, PointF end, Brush brush, DrawingOptions options, Pen pen) { brush = this.NormalizeBrush(brush); GraphicsOptions graphicsOptions = options.GraphicsOptions; RasterizationMode rasterizationMode = graphicsOptions.Antialias ? RasterizationMode.Antialiased : RasterizationMode.Aliased; RectangleF bounds = StrokeLineSegmentCommand.GetConservativeBounds(start, end, pen); Rectangle interest = Rectangle.FromLTRB( (int)MathF.Floor(bounds.Left), (int)MathF.Floor(bounds.Top), (int)MathF.Ceiling(bounds.Right) + 1, (int)MathF.Ceiling(bounds.Bottom) + 1); RasterizerOptions rasterizerOptions = new( interest, options.ShapeOptions.IntersectionRule, rasterizationMode, RasterizerSamplingOrigin.PixelCenter, graphicsOptions.AntialiasThreshold); DrawingCanvasState state = this.ResolveState(); this.batcher.AddStrokeLineSegment( new StrokeLineSegmentCommand( start, end, brush, options, in rasterizerOptions, state.TargetBounds, state.DestinationOffset, pen, state.IsLayer)); } /// /// Enqueues one explicit stroked open polyline command using the current canvas state. /// private void PrepareStrokePolylineCompositionCore( PointF[] points, Brush brush, DrawingOptions options, Pen pen) { brush = this.NormalizeBrush(brush); GraphicsOptions graphicsOptions = options.GraphicsOptions; RasterizationMode rasterizationMode = graphicsOptions.Antialias ? RasterizationMode.Antialiased : RasterizationMode.Aliased; RectangleF bounds = StrokePolylineCommand.GetConservativeBounds(points, pen); Rectangle interest = Rectangle.FromLTRB( (int)MathF.Floor(bounds.Left), (int)MathF.Floor(bounds.Top), (int)MathF.Ceiling(bounds.Right) + 1, (int)MathF.Ceiling(bounds.Bottom) + 1); RasterizerOptions rasterizerOptions = new( interest, options.ShapeOptions.IntersectionRule, rasterizationMode, RasterizerSamplingOrigin.PixelCenter, graphicsOptions.AntialiasThreshold); DrawingCanvasState state = this.ResolveState(); this.batcher.AddStrokePolyline( new StrokePolylineCommand( points, brush, options, in rasterizerOptions, state.TargetBounds, state.DestinationOffset, pen, state.IsLayer)); } /// /// Normalizes brushes that carry image sources containing the wrong pixel format exactly once. /// /// The logical brush supplied by the caller. /// The brush to queue for this canvas flush. private Brush NormalizeBrush(Brush brush) { if (brush is not ImageBrush imageBrush) { return brush; } if (brush is ImageBrush typedBrush) { return typedBrush; } // Normalize the source image once so deferred composition does not repeat per-pixel conversions. Image convertedImage = imageBrush.UntypedImage.CloneAs(); this.pendingImageResources.Add(convertedImage); return new ImageBrush(convertedImage, imageBrush.SourceRegion, imageBrush.Offset); } /// /// Enqueues a fill command for one path using the current canvas state. /// /// Brush used for shading. /// Path to fill. private void EnqueueFillPath(Brush brush, IPath path) { DrawingCanvasState state = this.ResolveState(); IPath closed = path.AsClosedPath(); this.PrepareCompositionCore( closed, brush, state.Options, RasterizerSamplingOrigin.PixelBoundary, state.ClipPaths); } /// /// Converts rendered text operations to composition commands and submits them to the batcher. /// /// Text drawing operations produced by glyph layout/rendering. /// Drawing options applied to each operation. /// Clip paths resolved from effective canvas state. private void DrawTextOperations( List operations, DrawingOptions drawingOptions, IReadOnlyList clipPaths) { // Build composition commands and 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). List<(byte RenderPass, int Sequence, CompositionSceneCommand Command)> entries = new(operations.Count); for (int i = 0; i < operations.Count; i++) { DrawingOperation operation = operations[i]; entries.Add((operation.RenderPass, i, this.CreateTextCompositionCommand(operation, drawingOptions, clipPaths))); } entries.Sort(static (a, b) => { int cmp = a.RenderPass.CompareTo(b.RenderPass); return cmp != 0 ? cmp : a.Sequence.CompareTo(b.Sequence); }); for (int i = 0; i < entries.Count; i++) { if (entries[i].Command is PathCompositionSceneCommand pathCommand) { this.batcher.AddComposition(pathCommand.Command); } else { this.batcher.AddStrokePath(((StrokePathCompositionSceneCommand)entries[i].Command).Command); } } } /// /// Resolves the currently active drawing state. /// /// The current state. private DrawingCanvasState ResolveState() => this.savedStates.Peek(); /// /// Ensures text drawing has at least one paint source. /// /// Optional fill brush. /// Optional outline pen. private static void EnsureTextPaint(Brush? brush, Pen? pen) { if (brush is null && pen is null) { throw new ArgumentException($"Expected a {nameof(brush)} or {nameof(pen)}. Both were null"); } } /// /// Executes an action with a temporary scoped state, restoring the previous scoped state afterwards. /// /// Temporary drawing options. /// Temporary clip paths. /// Action to execute. private void ExecuteWithTemporaryState(DrawingOptions options, IReadOnlyList clipPaths, Action action) { int saveCount = this.savedStates.Count; _ = this.SaveCore(options, clipPaths); try { action(); } finally { this.RestoreTo(saveCount); } } /// public override void Flush() { this.EnsureNotDisposed(); this.batcher.SealCommands(); } /// public override void Dispose() { if (this.isDisposed) { return; } try { // Dispose should finalize the same drawing state transitions as RestoreTo(1), // otherwise active layers can composite with different options than an explicit restore. this.RestoreToCore(1); if (this.ownsBatcher) { this.RenderRecordedTimeline(); } } finally { if (this.ownsBatcher) { this.DisposePendingImageResources(); } // Release the per-canvas glyph-outline cache. this.glyphCache.Clear(); this.isDisposed = true; } } /// /// Ensures this instance is not disposed. /// private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(this.isDisposed, this); /// /// Renders the recorded timeline owned by the root canvas during disposal. /// /// /// Command-range entries are lowered to short-lived backend scenes here. Scene entries /// reference retained scenes that were recorded earlier through . /// private void RenderRecordedTimeline() { if (!this.batcher.HasRecordedWork) { return; } this.batcher.SealAndPrepareCommands(); try { for (int i = 0; i < this.batcher.TimelineEntryCount; i++) { DrawingCanvasTimelineEntry entry = this.batcher.GetEntry(i); switch (entry.Kind) { case DrawingCanvasTimelineEntryKind.CommandRange: this.RenderCommandBatch(this.batcher.CreateCommandBatch(entry)); break; case DrawingCanvasTimelineEntryKind.ApplyBarrier: this.RenderApplyBarrier(this.batcher.GetApplyBarrier(entry.Index)); break; case DrawingCanvasTimelineEntryKind.Scene: this.backend.RenderScene( this.configuration, this.targetFrame, this.batcher.GetInsertedScene(entry.Index)); break; } } } finally { this.batcher.ClearCommandBatch(); } } /// /// Creates and renders one backend scene for a prepared command batch. /// /// The command batch to render. private void RenderCommandBatch(DrawingCommandBatch commandBatch) { using DrawingBackendScene scene = this.backend.CreateScene( this.configuration, this.targetFrame.Bounds, commandBatch); this.backend.RenderScene(this.configuration, this.targetFrame, scene); } /// /// Executes one apply barrier at its replay position. /// /// The apply barrier to execute. private void RenderApplyBarrier(ApplyBarrier barrier) { DrawingCommandBatch? maybeCommandBatch = barrier.CreateWriteBackBatch( this.configuration, this.backend, this.targetFrame, out IDisposable? ownedResource); if (maybeCommandBatch is not DrawingCommandBatch commandBatch) { return; } try { this.RenderCommandBatch(commandBatch); } finally { ownedResource?.Dispose(); } } /// /// Restores the saved-state stack to without public guard checks. /// Layer states are unwound through the normal compositing path so restore and disposal /// preserve identical layer semantics. /// /// The target stack depth to restore to. private void RestoreToCore(int saveCount) { while (this.savedStates.Count > saveCount) { DrawingCanvasState popped = this.savedStates.Pop(); if (popped.IsLayer) { // Restore and Dispose unwind layers through the same command stream path. this.batcher.AddComposition(CompositionCommand.CreateEndLayer(popped.TargetBounds, popped.LayerOptions!)); } } } /// /// Normalizes text options to avoid applying origin translation twice when path-based text is used. /// /// Input text options. /// Optional path to draw the text along. /// The path translated into text layout space when needed. /// Normalized text options for rendering. private static RichTextOptions ConfigureTextOptions(RichTextOptions options, IPath? path, out IPath? configuredPath) { configuredPath = path; if (path is not null && options.Origin != Vector2.Zero) { // Path-based text uses the path itself as positioning source; fold origin into the path // to avoid applying both path layout and origin translation. configuredPath = path.Translate(options.Origin); return new RichTextOptions(options) { Origin = Vector2.Zero }; } return options; } /// /// Builds a normalized composition command for a text drawing operation. /// /// The source drawing operation. /// Drawing options applied to the operation. /// Optional clip paths to apply during preparation. /// A composition scene command ready for batching. private CompositionSceneCommand CreateTextCompositionCommand( DrawingOperation operation, DrawingOptions drawingOptions, IReadOnlyList? clipPaths = null) { Brush compositeBrush = operation.Kind == DrawingOperationKind.Fill ? operation.Brush! : operation.Pen!.StrokeFill; GraphicsOptions graphicsOptions = drawingOptions.GraphicsOptions.CloneOrReturnForRules( operation.PixelAlphaCompositionMode, operation.PixelColorBlendingMode); RasterizationMode rasterizationMode = graphicsOptions.Antialias ? RasterizationMode.Antialiased : RasterizationMode.Aliased; ShapeOptions shapeOptions = drawingOptions.ShapeOptions; DrawingCanvasState state = this.ResolveState(); Point destinationOffset = new( state.DestinationOffset.X + operation.RenderLocation.X, state.DestinationOffset.Y + operation.RenderLocation.Y); Pen? pen = operation.Kind == DrawingOperationKind.Draw ? operation.Pen : null; IntersectionRule intersectionRule = pen is not null && operation.IntersectionRule != IntersectionRule.NonZero ? IntersectionRule.NonZero : operation.IntersectionRule; RasterizerSamplingOrigin samplingOrigin = pen is not null ? RasterizerSamplingOrigin.PixelCenter : RasterizerSamplingOrigin.PixelBoundary; RasterizerOptions rasterizerOptions = new( default, intersectionRule, rasterizationMode, samplingOrigin, graphicsOptions.AntialiasThreshold); // Glyph paths arrive pre-laid-out, so the queued command must report identity transform // and the GraphicsOptions clone produced above. Reuse the caller's instance only when both already match. DrawingOptions effectiveOptions = ReferenceEquals(graphicsOptions, drawingOptions.GraphicsOptions) && drawingOptions.Transform == Matrix4x4.Identity ? drawingOptions : new DrawingOptions(graphicsOptions, shapeOptions, Matrix4x4.Identity); IReadOnlyList? operationClipPaths = clipPaths; if (clipPaths != null && clipPaths.Count > 0 && (operation.RenderLocation.X != 0 || operation.RenderLocation.Y != 0)) { IPath[] translatedClipPaths = new IPath[clipPaths.Count]; // Text glyph paths are queued in glyph-local coordinates and placed with RenderLocation, // so canvas-space clip paths must be moved into that same local space before clipping. for (int i = 0; i < clipPaths.Count; i++) { translatedClipPaths[i] = clipPaths[i].Translate(-operation.RenderLocation); } operationClipPaths = translatedClipPaths; } if (pen is null) { return new PathCompositionSceneCommand( CompositionCommand.Create( operation.Path, compositeBrush, effectiveOptions, in rasterizerOptions, state.TargetBounds, destinationOffset, operationClipPaths, state.IsLayer)); } return new StrokePathCompositionSceneCommand( new StrokePathCommand( operation.Path, compositeBrush, effectiveOptions, in rasterizerOptions, state.TargetBounds, destinationOffset, pen, operationClipPaths, state.IsLayer)); } /// /// Converts floating bounds to a conservative integer rectangle using floor/ceiling. /// /// The floating bounds to convert. /// A rectangle covering the full floating bounds extent. private static Rectangle ToConservativeBounds(RectangleF bounds) => Rectangle.FromLTRB( (int)MathF.Floor(bounds.Left), (int)MathF.Floor(bounds.Top), (int)MathF.Ceiling(bounds.Right), (int)MathF.Ceiling(bounds.Bottom)); /// /// Resolves local layer bounds to absolute target bounds using the active transform. /// /// The current drawing state. /// The layer bounds in local canvas coordinates. /// The absolute layer bounds clipped to the active target. private static Rectangle ResolveLayerBounds(DrawingCanvasState state, Rectangle bounds) { RectangleF transformedBounds = bounds; Matrix4x4 transform = state.Options.Transform; if (!transform.IsIdentity) { transformedBounds = RectangleF.Transform(transformedBounds, transform); } Rectangle localLayerBounds = ToConservativeBounds(transformedBounds); Rectangle absoluteLayerBounds = new( state.DestinationOffset.X + localLayerBounds.X, state.DestinationOffset.Y + localLayerBounds.Y, localLayerBounds.Width, localLayerBounds.Height); return Rectangle.Intersect(state.TargetBounds, absoluteLayerBounds); } /// /// Creates resize options used for image drawing operations. /// /// Requested output size. /// Optional resampler. Defaults to bicubic. /// A resize options instance configured for stretch behavior. private static ResizeOptions CreateDrawImageResizeOptions(Size size, IResampler? sampler) => new() { Size = size, Mode = ResizeMode.Stretch, Sampler = sampler ?? KnownResamplers.Bicubic }; /// /// Creates a scaled image for drawing, optionally cropping to a source region first. /// /// The source image. /// The clipped source rectangle. /// The target scaled size. /// Optional resampler used for scaling. /// A new image containing the scaled pixels. private static Image CreateScaledDrawImage( Image image, Rectangle clippedSourceRect, Size scaledSize, IResampler? sampler) { ResizeOptions effectiveResizeOptions = CreateDrawImageResizeOptions(scaledSize, sampler); if (clippedSourceRect == image.Bounds) { return image.Clone(ctx => ctx.Resize(effectiveResizeOptions)); } Image result = image.Clone(ctx => ctx.Crop(clippedSourceRect)); result.Mutate(ctx => ctx.Resize(effectiveResizeOptions)); return result; } /// /// Applies a transform to image content and returns the transformed image. /// /// The source image. /// Destination rectangle in canvas coordinates. /// Canvas transform to apply. /// Optional resampler used during transform. /// Receives the transformed destination bounds. /// A new image containing transformed pixels. private static Image CreateTransformedDrawImage( Image image, RectangleF destinationRect, Matrix4x4 transform, IResampler? sampler, out RectangleF transformedDestinationRect) { // Source space: pixel coordinates in the untransformed source image (0..Width, 0..Height). // Destination space: where that image would land on the canvas without any extra transform. // This matrix maps source -> destination by scaling to destination size then translating to destination origin. Matrix4x4 sourceToDestination = Matrix4x4.CreateScale( destinationRect.Width / image.Width, destinationRect.Height / image.Height, 1) * Matrix4x4.CreateTranslation(destinationRect.X, destinationRect.Y, 0); // Apply the canvas transform after source->destination placement: // source -> destination -> transformed-canvas. Matrix4x4 sourceToTransformedCanvas = sourceToDestination * transform; // Compute the transformed axis-aligned bounds in canvas space. RectangleF transformedBounds = RectangleF.Transform( new RectangleF(0, 0, image.Width, image.Height), sourceToTransformedCanvas); // ImageBrush samples against integer pixel locations. Align the baked bitmap to integer // canvas bounds so the bitmap origin and brush sampling origin agree exactly. int alignedLeft = (int)MathF.Floor(transformedBounds.Left); int alignedTop = (int)MathF.Floor(transformedBounds.Top); int alignedRight = (int)MathF.Ceiling(transformedBounds.Right); int alignedBottom = (int)MathF.Ceiling(transformedBounds.Bottom); transformedDestinationRect = RectangleF.FromLTRB( alignedLeft, alignedTop, alignedRight, alignedBottom); Size targetSize = new( Math.Max(1, alignedRight - alignedLeft), Math.Max(1, alignedBottom - alignedTop)); // ImageSharp.Transform expects output coordinates relative to the output bitmap origin (0,0). // Shift transformed-canvas coordinates so the aligned integer canvas bounds become 0,0. Matrix4x4 sourceToTarget = sourceToTransformedCanvas * Matrix4x4.CreateTranslation(-alignedLeft, -alignedTop, 0); // Resample source pixels into the target bitmap using the computed source->target mapping. return image.Clone(ctx => ctx.Transform( image.Bounds, sourceToTarget, targetSize, sampler ?? KnownResamplers.Bicubic)); } /// /// Maps a clipped source rectangle back to the corresponding destination rectangle. /// /// Original source rectangle. /// Original destination rectangle. /// Source rectangle clipped to image bounds. /// The destination rectangle corresponding to the clipped source region. private static RectangleF MapSourceClipToDestination( Rectangle sourceRect, RectangleF destinationRect, Rectangle clippedSourceRect) { float scaleX = destinationRect.Width / sourceRect.Width; float scaleY = destinationRect.Height / sourceRect.Height; float left = destinationRect.Left + ((clippedSourceRect.Left - sourceRect.Left) * scaleX); float top = destinationRect.Top + ((clippedSourceRect.Top - sourceRect.Top) * scaleY); float width = clippedSourceRect.Width * scaleX; float height = clippedSourceRect.Height * scaleY; return new RectangleF(left, top, width, height); } /// /// Transforms clip paths into the same coordinate space as an eagerly-transformed draw-image command. /// /// Clip paths from the current canvas state. /// Canvas transform already applied to the image content. /// The transformed clip path list. private static IReadOnlyList TransformClipPaths(IReadOnlyList clipPaths, Matrix4x4 transform) { if (clipPaths.Count == 0 || transform.IsIdentity) { return clipPaths; } IPath[] transformed = new IPath[clipPaths.Count]; for (int i = 0; i < transformed.Length; i++) { transformed[i] = clipPaths[i].Transform(transform); } return transformed; } /// /// Disposes image resources retained for deferred draw execution. /// private void DisposePendingImageResources() { if (this.pendingImageResources.Count == 0) { return; } // Release deferred image resources once queued operations have executed. for (int i = 0; i < this.pendingImageResources.Count; i++) { this.pendingImageResources[i].Dispose(); } this.pendingImageResources.Clear(); } /// /// Transfers pending image resources to a retained scene. /// /// The resources that must remain alive for the retained scene, or when none exist. private IDisposable[]? DetachPendingImageResources() { if (this.pendingImageResources.Count == 0) { return null; } IDisposable[] resources = new IDisposable[this.pendingImageResources.Count]; for (int i = 0; i < this.pendingImageResources.Count; i++) { resources[i] = this.pendingImageResources[i]; } this.pendingImageResources.Clear(); return resources; } /// /// Disposes resources that failed to transfer to a retained scene. /// /// The resources to dispose. private static void DisposeOwnedResources(IDisposable[]? resources) { if (resources is null) { return; } for (int i = 0; i < resources.Length; i++) { resources[i].Dispose(); } } } }