1642 lines
64 KiB
C#
1642 lines
64 KiB
C#
// 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 {
|
|
/// <summary>
|
|
/// A drawing canvas over a frame target.
|
|
/// </summary>
|
|
/// <typeparam name="TPixel">The pixel format.</typeparam>
|
|
public sealed class DrawingCanvas<TPixel> : DrawingCanvas
|
|
where TPixel : unmanaged, IPixel<TPixel>
|
|
{
|
|
/// <summary>
|
|
/// Processing configuration used by operations executed through this canvas.
|
|
/// </summary>
|
|
private readonly Configuration configuration;
|
|
|
|
/// <summary>
|
|
/// Backend responsible for rasterizing and composing draw commands.
|
|
/// </summary>
|
|
private readonly IDrawingBackend backend;
|
|
|
|
/// <summary>
|
|
/// Destination frame receiving rendered output.
|
|
/// </summary>
|
|
private readonly ICanvasFrame<TPixel> targetFrame;
|
|
|
|
/// <summary>
|
|
/// Command batcher used to defer and submit composition commands.
|
|
/// </summary>
|
|
private readonly DrawingCanvasBatcher<TPixel> batcher;
|
|
|
|
/// <summary>
|
|
/// Temporary image resources that must stay alive until queued commands are flushed.
|
|
/// </summary>
|
|
private readonly List<Image<TPixel>> pendingImageResources = [];
|
|
|
|
/// <summary>
|
|
/// Indicates whether this canvas owns final disposal of the shared batcher.
|
|
/// </summary>
|
|
private readonly bool ownsBatcher;
|
|
|
|
/// <summary>
|
|
/// Tracks whether this instance has already been disposed.
|
|
/// </summary>
|
|
private bool isDisposed;
|
|
|
|
/// <summary>
|
|
/// Stack of saved drawing states for Save/Restore operations.
|
|
/// </summary>
|
|
private readonly Stack<DrawingCanvasState> 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<RichTextGlyphRenderer.CacheKey, List<RichTextGlyphRenderer.GlyphRenderData>> glyphCache = [];
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DrawingCanvas{TPixel}"/> class.
|
|
/// </summary>
|
|
/// <param name="configuration">The active processing configuration.</param>
|
|
/// <param name="options">Initial drawing options for this canvas instance.</param>
|
|
/// <param name="targetRegion">The destination target region.</param>
|
|
/// <param name="clipPaths">Initial clip paths for this canvas instance.</param>
|
|
public DrawingCanvas(
|
|
Configuration configuration,
|
|
DrawingOptions options,
|
|
Buffer2DRegion<TPixel> targetRegion,
|
|
params IPath[] clipPaths)
|
|
: this(configuration, options, new MemoryCanvasFrame<TPixel>(targetRegion), clipPaths)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DrawingCanvas{TPixel}"/> class.
|
|
/// </summary>
|
|
/// <param name="configuration">The active processing configuration.</param>
|
|
/// <param name="options">Initial drawing options for this canvas instance.</param>
|
|
/// <param name="targetFrame">The destination frame.</param>
|
|
/// <param name="clipPaths">Initial clip paths for this canvas instance.</param>
|
|
public DrawingCanvas(
|
|
Configuration configuration,
|
|
DrawingOptions options,
|
|
ICanvasFrame<TPixel> targetFrame,
|
|
params IPath[] clipPaths)
|
|
: this(configuration, options, configuration.GetDrawingBackend(), targetFrame, clipPaths)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DrawingCanvas{TPixel}"/> class with an explicit backend and initial state.
|
|
/// </summary>
|
|
/// <param name="configuration">The active processing configuration.</param>
|
|
/// <param name="options">Initial drawing options for this canvas instance.</param>
|
|
/// <param name="backend">The drawing backend implementation.</param>
|
|
/// <param name="targetFrame">The destination frame.</param>
|
|
/// <param name="clipPaths">Initial clip paths for this canvas instance.</param>
|
|
public DrawingCanvas(
|
|
Configuration configuration,
|
|
DrawingOptions options,
|
|
IDrawingBackend backend,
|
|
ICanvasFrame<TPixel> targetFrame,
|
|
params IPath[] clipPaths)
|
|
: this(
|
|
configuration,
|
|
backend,
|
|
targetFrame,
|
|
new DrawingCanvasBatcher<TPixel>(configuration),
|
|
new DrawingCanvasState(options, clipPaths, targetFrame.Bounds, targetFrame.Bounds.Location),
|
|
true)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="DrawingCanvas{TPixel}"/> class
|
|
/// with explicit backend and batcher instances.
|
|
/// </summary>
|
|
/// <param name="configuration">The active processing configuration.</param>
|
|
/// <param name="backend">The drawing backend implementation.</param>
|
|
/// <param name="targetFrame">The destination frame.</param>
|
|
/// <param name="batcher">The command batcher used for deferred composition.</param>
|
|
/// <param name="defaultState">The default state used when no scoped state is active.</param>
|
|
/// <param name="ownsBatcher">Whether this canvas owns final disposal of the shared batcher.</param>
|
|
private DrawingCanvas(
|
|
Configuration configuration,
|
|
IDrawingBackend backend,
|
|
ICanvasFrame<TPixel> targetFrame,
|
|
DrawingCanvasBatcher<TPixel> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override Rectangle Bounds { get; }
|
|
|
|
/// <inheritdoc />
|
|
public override int SaveCount => this.savedStates.Count;
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override int Save(DrawingOptions options, params IPath[] clipPaths)
|
|
=> this.SaveCore(options, clipPaths);
|
|
|
|
private int SaveCore(DrawingOptions options, IReadOnlyList<IPath> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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!));
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void RestoreTo(int saveCount)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
Guard.MustBeBetweenOrEqualTo(saveCount, 1, this.savedStates.Count, nameof(saveCount));
|
|
|
|
this.RestoreToCore(saveCount);
|
|
}
|
|
|
|
/// <inheritdoc cref="DrawingCanvas.CreateRegion(Rectangle)" />
|
|
public override DrawingCanvas<TPixel> CreateRegion(Rectangle region)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
|
|
Rectangle clipped = Rectangle.Intersect(this.Bounds, region);
|
|
CanvasRegionFrame<TPixel> 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<TPixel>(
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void Fill(Brush brush, IPath path)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
Guard.NotNull(path, nameof(path));
|
|
Guard.NotNull(brush, nameof(brush));
|
|
this.EnqueueFillPath(brush, path);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void Apply(Rectangle region, Action<IImageProcessingContext> operation)
|
|
=> this.Apply(new RectanglePolygon(region), operation);
|
|
|
|
/// <inheritdoc />
|
|
public override void Apply(PathBuilder pathBuilder, Action<IImageProcessingContext> operation)
|
|
{
|
|
Guard.NotNull(pathBuilder, nameof(pathBuilder));
|
|
this.Apply(pathBuilder.Build(), operation);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void Apply(IPath path, Action<IImageProcessingContext> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws a two-point line segment using the provided pen and drawing options.
|
|
/// </summary>
|
|
/// <param name="pen">Pen used to generate the line outline.</param>
|
|
/// <param name="start">Line start point.</param>
|
|
/// <param name="end">Line end point.</param>
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void DrawText(
|
|
RichTextOptions textOptions,
|
|
ReadOnlySpan<char> text,
|
|
Brush? brush,
|
|
Pen? pen)
|
|
=> this.DrawTextCore(textOptions, text, path: null, brush, pen);
|
|
|
|
/// <inheritdoc />
|
|
public override void DrawText(
|
|
RichTextOptions textOptions,
|
|
ReadOnlySpan<char> 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<char> 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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void DrawGlyphs(
|
|
Brush brush,
|
|
Pen pen,
|
|
IEnumerable<GlyphPathCollection> 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<IPath> 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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override TextMetrics MeasureText(RichTextOptions textOptions, ReadOnlySpan<char> text)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
return TextMeasurer.Measure(text, textOptions);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void DrawImage(
|
|
Image image,
|
|
Rectangle sourceRect,
|
|
RectangleF destinationRect,
|
|
IResampler? sampler)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
Guard.NotNull(image, nameof(image));
|
|
|
|
if (image is Image<TPixel> specificImage)
|
|
{
|
|
this.DrawImageCore(specificImage, sourceRect, destinationRect, sampler, ownsSourceImage: false);
|
|
return;
|
|
}
|
|
|
|
Image<TPixel> convertedImage = image.CloneAs<TPixel>();
|
|
this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true);
|
|
}
|
|
|
|
/// <inheritdoc cref="DrawingCanvas.DrawImage(Image, Rectangle, RectangleF, IResampler?)" />
|
|
public void DrawImage(
|
|
Image<TPixel> image,
|
|
Rectangle sourceRect,
|
|
RectangleF destinationRect,
|
|
IResampler? sampler = null)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
Guard.NotNull(image, nameof(image));
|
|
this.DrawImageCore(image, sourceRect, destinationRect, sampler, ownsSourceImage: false);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void RenderScene(DrawingBackendScene scene)
|
|
{
|
|
this.EnsureNotDisposed();
|
|
Guard.NotNull(scene, nameof(scene));
|
|
this.batcher.AddScene(scene);
|
|
}
|
|
|
|
private void DrawImageCore(
|
|
Image<TPixel> image,
|
|
Rectangle sourceRect,
|
|
RectangleF destinationRect,
|
|
IResampler? sampler,
|
|
bool ownsSourceImage)
|
|
{
|
|
bool disposeSourceImage = ownsSourceImage;
|
|
|
|
DrawingCanvasState state = this.ResolveState();
|
|
DrawingOptions effectiveOptions = state.Options;
|
|
DrawingOptions commandOptions = effectiveOptions;
|
|
IReadOnlyList<IPath> 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<TPixel> brushImage = image;
|
|
RectangleF brushImageRegion = clippedSourceRect;
|
|
RectangleF renderDestinationRect = clippedDestinationRect;
|
|
Image<TPixel>? 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<TPixel> 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<TPixel> 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prepares a path fill composition command and enqueues it in the batcher.
|
|
/// </summary>
|
|
/// <param name="path">Path to fill.</param>
|
|
/// <param name="brush">Brush used for shading.</param>
|
|
/// <param name="options">Effective drawing options.</param>
|
|
/// <param name="samplingOrigin">Rasterizer sampling origin.</param>
|
|
/// <param name="clipPaths">Optional clip paths to apply during preparation.</param>
|
|
/// <param name="pen">Optional pen for stroke commands.</param>
|
|
private void PrepareCompositionCore(
|
|
IPath path,
|
|
Brush brush,
|
|
DrawingOptions options,
|
|
RasterizerSamplingOrigin samplingOrigin,
|
|
IReadOnlyList<IPath>? 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues one explicit two-point stroke line-segment command using the current canvas state.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues one explicit stroked open polyline command using the current canvas state.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes brushes that carry image sources containing the wrong pixel format exactly once.
|
|
/// </summary>
|
|
/// <param name="brush">The logical brush supplied by the caller.</param>
|
|
/// <returns>The brush to queue for this canvas flush.</returns>
|
|
private Brush NormalizeBrush(Brush brush)
|
|
{
|
|
if (brush is not ImageBrush imageBrush)
|
|
{
|
|
return brush;
|
|
}
|
|
|
|
if (brush is ImageBrush<TPixel> typedBrush)
|
|
{
|
|
return typedBrush;
|
|
}
|
|
|
|
// Normalize the source image once so deferred composition does not repeat per-pixel conversions.
|
|
Image<TPixel> convertedImage = imageBrush.UntypedImage.CloneAs<TPixel>();
|
|
this.pendingImageResources.Add(convertedImage);
|
|
return new ImageBrush<TPixel>(convertedImage, imageBrush.SourceRegion, imageBrush.Offset);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues a fill command for one path using the current canvas state.
|
|
/// </summary>
|
|
/// <param name="brush">Brush used for shading.</param>
|
|
/// <param name="path">Path to fill.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts rendered text operations to composition commands and submits them to the batcher.
|
|
/// </summary>
|
|
/// <param name="operations">Text drawing operations produced by glyph layout/rendering.</param>
|
|
/// <param name="drawingOptions">Drawing options applied to each operation.</param>
|
|
/// <param name="clipPaths">Clip paths resolved from effective canvas state.</param>
|
|
private void DrawTextOperations(
|
|
List<DrawingOperation> operations,
|
|
DrawingOptions drawingOptions,
|
|
IReadOnlyList<IPath> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the currently active drawing state.
|
|
/// </summary>
|
|
/// <returns>The current state.</returns>
|
|
private DrawingCanvasState ResolveState() => this.savedStates.Peek();
|
|
|
|
/// <summary>
|
|
/// Ensures text drawing has at least one paint source.
|
|
/// </summary>
|
|
/// <param name="brush">Optional fill brush.</param>
|
|
/// <param name="pen">Optional outline pen.</param>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes an action with a temporary scoped state, restoring the previous scoped state afterwards.
|
|
/// </summary>
|
|
/// <param name="options">Temporary drawing options.</param>
|
|
/// <param name="clipPaths">Temporary clip paths.</param>
|
|
/// <param name="action">Action to execute.</param>
|
|
private void ExecuteWithTemporaryState(DrawingOptions options, IReadOnlyList<IPath> clipPaths, Action action)
|
|
{
|
|
int saveCount = this.savedStates.Count;
|
|
_ = this.SaveCore(options, clipPaths);
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
finally
|
|
{
|
|
this.RestoreTo(saveCount);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override void Flush()
|
|
{
|
|
this.EnsureNotDisposed();
|
|
this.batcher.SealCommands();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures this instance is not disposed.
|
|
/// </summary>
|
|
private void EnsureNotDisposed()
|
|
=> ObjectDisposedException.ThrowIf(this.isDisposed, this);
|
|
|
|
/// <summary>
|
|
/// Renders the recorded timeline owned by the root canvas during disposal.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Command-range entries are lowered to short-lived backend scenes here. Scene entries
|
|
/// reference retained scenes that were recorded earlier through <see cref="DrawingCanvas.RenderScene"/>.
|
|
/// </remarks>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates and renders one backend scene for a prepared command batch.
|
|
/// </summary>
|
|
/// <param name="commandBatch">The command batch to render.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes one apply barrier at its replay position.
|
|
/// </summary>
|
|
/// <param name="barrier">The apply barrier to execute.</param>
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores the saved-state stack to <paramref name="saveCount"/> without public guard checks.
|
|
/// Layer states are unwound through the normal compositing path so restore and disposal
|
|
/// preserve identical layer semantics.
|
|
/// </summary>
|
|
/// <param name="saveCount">The target stack depth to restore to.</param>
|
|
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!));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes text options to avoid applying origin translation twice when path-based text is used.
|
|
/// </summary>
|
|
/// <param name="options">Input text options.</param>
|
|
/// <param name="path">Optional path to draw the text along.</param>
|
|
/// <param name="configuredPath">The path translated into text layout space when needed.</param>
|
|
/// <returns>Normalized text options for rendering.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a normalized composition command for a text drawing operation.
|
|
/// </summary>
|
|
/// <param name="operation">The source drawing operation.</param>
|
|
/// <param name="drawingOptions">Drawing options applied to the operation.</param>
|
|
/// <param name="clipPaths">Optional clip paths to apply during preparation.</param>
|
|
/// <returns>A composition scene command ready for batching.</returns>
|
|
private CompositionSceneCommand CreateTextCompositionCommand(
|
|
DrawingOperation operation,
|
|
DrawingOptions drawingOptions,
|
|
IReadOnlyList<IPath>? 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<IPath>? 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts floating bounds to a conservative integer rectangle using floor/ceiling.
|
|
/// </summary>
|
|
/// <param name="bounds">The floating bounds to convert.</param>
|
|
/// <returns>A rectangle covering the full floating bounds extent.</returns>
|
|
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));
|
|
|
|
/// <summary>
|
|
/// Resolves local layer bounds to absolute target bounds using the active transform.
|
|
/// </summary>
|
|
/// <param name="state">The current drawing state.</param>
|
|
/// <param name="bounds">The layer bounds in local canvas coordinates.</param>
|
|
/// <returns>The absolute layer bounds clipped to the active target.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates resize options used for image drawing operations.
|
|
/// </summary>
|
|
/// <param name="size">Requested output size.</param>
|
|
/// <param name="sampler">Optional resampler. Defaults to bicubic.</param>
|
|
/// <returns>A resize options instance configured for stretch behavior.</returns>
|
|
private static ResizeOptions CreateDrawImageResizeOptions(Size size, IResampler? sampler)
|
|
=> new()
|
|
{
|
|
Size = size,
|
|
Mode = ResizeMode.Stretch,
|
|
Sampler = sampler ?? KnownResamplers.Bicubic
|
|
};
|
|
|
|
/// <summary>
|
|
/// Creates a scaled image for drawing, optionally cropping to a source region first.
|
|
/// </summary>
|
|
/// <param name="image">The source image.</param>
|
|
/// <param name="clippedSourceRect">The clipped source rectangle.</param>
|
|
/// <param name="scaledSize">The target scaled size.</param>
|
|
/// <param name="sampler">Optional resampler used for scaling.</param>
|
|
/// <returns>A new image containing the scaled pixels.</returns>
|
|
private static Image<TPixel> CreateScaledDrawImage(
|
|
Image<TPixel> image,
|
|
Rectangle clippedSourceRect,
|
|
Size scaledSize,
|
|
IResampler? sampler)
|
|
{
|
|
ResizeOptions effectiveResizeOptions = CreateDrawImageResizeOptions(scaledSize, sampler);
|
|
if (clippedSourceRect == image.Bounds)
|
|
{
|
|
return image.Clone(ctx => ctx.Resize(effectiveResizeOptions));
|
|
}
|
|
|
|
Image<TPixel> result = image.Clone(ctx => ctx.Crop(clippedSourceRect));
|
|
result.Mutate(ctx => ctx.Resize(effectiveResizeOptions));
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a transform to image content and returns the transformed image.
|
|
/// </summary>
|
|
/// <param name="image">The source image.</param>
|
|
/// <param name="destinationRect">Destination rectangle in canvas coordinates.</param>
|
|
/// <param name="transform">Canvas transform to apply.</param>
|
|
/// <param name="sampler">Optional resampler used during transform.</param>
|
|
/// <param name="transformedDestinationRect">Receives the transformed destination bounds.</param>
|
|
/// <returns>A new image containing transformed pixels.</returns>
|
|
private static Image<TPixel> CreateTransformedDrawImage(
|
|
Image<TPixel> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a clipped source rectangle back to the corresponding destination rectangle.
|
|
/// </summary>
|
|
/// <param name="sourceRect">Original source rectangle.</param>
|
|
/// <param name="destinationRect">Original destination rectangle.</param>
|
|
/// <param name="clippedSourceRect">Source rectangle clipped to image bounds.</param>
|
|
/// <returns>The destination rectangle corresponding to the clipped source region.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transforms clip paths into the same coordinate space as an eagerly-transformed draw-image command.
|
|
/// </summary>
|
|
/// <param name="clipPaths">Clip paths from the current canvas state.</param>
|
|
/// <param name="transform">Canvas transform already applied to the image content.</param>
|
|
/// <returns>The transformed clip path list.</returns>
|
|
private static IReadOnlyList<IPath> TransformClipPaths(IReadOnlyList<IPath> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes image resources retained for deferred draw execution.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transfers pending image resources to a retained scene.
|
|
/// </summary>
|
|
/// <returns>The resources that must remain alive for the retained scene, or <see langword="null"/> when none exist.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes resources that failed to transfer to a retained scene.
|
|
/// </summary>
|
|
/// <param name="resources">The resources to dispose.</param>
|
|
private static void DisposeOwnedResources(IDisposable[]? resources)
|
|
{
|
|
if (resources is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < resources.Length; i++)
|
|
{
|
|
resources[i].Dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|