diff --git a/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs b/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs new file mode 100644 index 0000000000..611676f5d8 --- /dev/null +++ b/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; + +namespace ShimSkiaSharp.Editing; + +public static class CanvasCommandVisitorExtensions +{ + public static void Accept(this CanvasCommand command, ICanvasCommandVisitor visitor) + { + if (command is null) + { + throw new ArgumentNullException(nameof(command)); + } + + if (visitor is null) + { + throw new ArgumentNullException(nameof(visitor)); + } + + switch (command) + { + case ClipPathCanvasCommand clipPath: + visitor.Visit(clipPath); + break; + case ClipRectCanvasCommand clipRect: + visitor.Visit(clipRect); + break; + case DrawImageCanvasCommand drawImage: + visitor.Visit(drawImage); + break; + case DrawPathCanvasCommand drawPath: + visitor.Visit(drawPath); + break; + case DrawTextBlobCanvasCommand drawTextBlob: + visitor.Visit(drawTextBlob); + break; + case DrawTextCanvasCommand drawText: + visitor.Visit(drawText); + break; + case DrawTextOnPathCanvasCommand drawTextOnPath: + visitor.Visit(drawTextOnPath); + break; + case RestoreCanvasCommand restore: + visitor.Visit(restore); + break; + case SaveCanvasCommand save: + visitor.Visit(save); + break; + case SaveLayerCanvasCommand saveLayer: + visitor.Visit(saveLayer); + break; + case SetMatrixCanvasCommand setMatrix: + visitor.Visit(setMatrix); + break; + default: + throw new NotSupportedException($"Unsupported {nameof(CanvasCommand)} type: {command.GetType().Name}."); + } + } +} diff --git a/src/ShimSkiaSharp/Editing/EditMode.cs b/src/ShimSkiaSharp/Editing/EditMode.cs new file mode 100644 index 0000000000..37228ee076 --- /dev/null +++ b/src/ShimSkiaSharp/Editing/EditMode.cs @@ -0,0 +1,9 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +namespace ShimSkiaSharp.Editing; + +public enum EditMode +{ + InPlace, + CloneOnWrite +} diff --git a/src/ShimSkiaSharp/Editing/SKPaintEditingExtensions.cs b/src/ShimSkiaSharp/Editing/SKPaintEditingExtensions.cs new file mode 100644 index 0000000000..2e96df6c2a --- /dev/null +++ b/src/ShimSkiaSharp/Editing/SKPaintEditingExtensions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; + +namespace ShimSkiaSharp.Editing; + +public static class SKPaintEditingExtensions +{ + public static void ApplyColorTransform(this SKPaint paint, Func transform) + { + if (paint is null) + { + throw new ArgumentNullException(nameof(paint)); + } + + if (transform is null) + { + throw new ArgumentNullException(nameof(transform)); + } + + if (paint.Color is { } color) + { + paint.Color = transform(color); + } + } + + public static void ApplyShaderTransform(this SKPaint paint, Func transform) + { + if (paint is null) + { + throw new ArgumentNullException(nameof(paint)); + } + + if (transform is null) + { + throw new ArgumentNullException(nameof(transform)); + } + + if (paint.Shader is { } shader) + { + paint.Shader = transform(shader); + } + } +} diff --git a/src/ShimSkiaSharp/Editing/SKPathEditingExtensions.cs b/src/ShimSkiaSharp/Editing/SKPathEditingExtensions.cs new file mode 100644 index 0000000000..8b111a78dc --- /dev/null +++ b/src/ShimSkiaSharp/Editing/SKPathEditingExtensions.cs @@ -0,0 +1,187 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; + +namespace ShimSkiaSharp.Editing; + +public static class SKPathEditingExtensions +{ + public static int UpdateCommands( + this SKPath path, + Func predicate, + Func replace) + { + if (path is null) + { + throw new ArgumentNullException(nameof(path)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (replace is null) + { + throw new ArgumentNullException(nameof(replace)); + } + + var commands = path.Commands; + if (commands is null) + { + return 0; + } + + var count = 0; + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + if (!predicate(command)) + { + continue; + } + + var next = replace(command); + if (!ReferenceEquals(next, command)) + { + commands[i] = next; + } + + count++; + } + + return count; + } + + public static void Transform(this SKPath path, SKMatrix matrix) + { + if (path is null) + { + throw new ArgumentNullException(nameof(path)); + } + + if (!IsAxisAligned(matrix)) + { + throw new NotSupportedException("Only translation/scale matrices are supported."); + } + + var commands = path.Commands; + if (commands is null) + { + return; + } + + var absScaleX = Math.Abs(matrix.ScaleX); + var absScaleY = Math.Abs(matrix.ScaleY); + var uniformScale = absScaleX == absScaleY; + var flipSweep = matrix.ScaleX * matrix.ScaleY < 0f; + + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + switch (command) + { + case MoveToPathCommand moveTo: + { + var mapped = matrix.MapPoint(new SKPoint(moveTo.X, moveTo.Y)); + commands[i] = new MoveToPathCommand(mapped.X, mapped.Y); + break; + } + case LineToPathCommand lineTo: + { + var mapped = matrix.MapPoint(new SKPoint(lineTo.X, lineTo.Y)); + commands[i] = new LineToPathCommand(mapped.X, mapped.Y); + break; + } + case QuadToPathCommand quadTo: + { + var p0 = matrix.MapPoint(new SKPoint(quadTo.X0, quadTo.Y0)); + var p1 = matrix.MapPoint(new SKPoint(quadTo.X1, quadTo.Y1)); + commands[i] = new QuadToPathCommand(p0.X, p0.Y, p1.X, p1.Y); + break; + } + case CubicToPathCommand cubicTo: + { + var p0 = matrix.MapPoint(new SKPoint(cubicTo.X0, cubicTo.Y0)); + var p1 = matrix.MapPoint(new SKPoint(cubicTo.X1, cubicTo.Y1)); + var p2 = matrix.MapPoint(new SKPoint(cubicTo.X2, cubicTo.Y2)); + commands[i] = new CubicToPathCommand(p0.X, p0.Y, p1.X, p1.Y, p2.X, p2.Y); + break; + } + case ArcToPathCommand arcTo: + { + var end = matrix.MapPoint(new SKPoint(arcTo.X, arcTo.Y)); + var rx = arcTo.Rx * absScaleX; + var ry = arcTo.Ry * absScaleY; + var sweep = arcTo.Sweep; + if (flipSweep) + { + sweep = sweep == SKPathDirection.Clockwise + ? SKPathDirection.CounterClockwise + : SKPathDirection.Clockwise; + } + commands[i] = new ArcToPathCommand(rx, ry, arcTo.XAxisRotate, arcTo.LargeArc, sweep, end.X, end.Y); + break; + } + case AddRectPathCommand addRect: + { + var rect = matrix.MapRect(addRect.Rect); + commands[i] = new AddRectPathCommand(rect); + break; + } + case AddRoundRectPathCommand addRoundRect: + { + var rect = matrix.MapRect(addRoundRect.Rect); + var rx = addRoundRect.Rx * absScaleX; + var ry = addRoundRect.Ry * absScaleY; + commands[i] = new AddRoundRectPathCommand(rect, rx, ry); + break; + } + case AddOvalPathCommand addOval: + { + var rect = matrix.MapRect(addOval.Rect); + commands[i] = new AddOvalPathCommand(rect); + break; + } + case AddCirclePathCommand addCircle: + { + var center = matrix.MapPoint(new SKPoint(addCircle.X, addCircle.Y)); + var rx = addCircle.Radius * absScaleX; + var ry = addCircle.Radius * absScaleY; + if (uniformScale) + { + commands[i] = new AddCirclePathCommand(center.X, center.Y, rx); + } + else + { + var rect = SKRect.Create(center.X - rx, center.Y - ry, rx * 2, ry * 2); + commands[i] = new AddOvalPathCommand(rect); + } + break; + } + case AddPolyPathCommand addPoly: + { + if (addPoly.Points is { } points) + { + var mapped = new SKPoint[points.Count]; + for (var j = 0; j < points.Count; j++) + { + mapped[j] = matrix.MapPoint(points[j]); + } + commands[i] = new AddPolyPathCommand(mapped, addPoly.Close); + } + break; + } + } + } + } + + private static bool IsAxisAligned(SKMatrix matrix) + { + return matrix.SkewX == 0f && + matrix.SkewY == 0f && + matrix.Persp0 == 0f && + matrix.Persp1 == 0f && + matrix.Persp2 == 1f; + } +} diff --git a/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs b/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs new file mode 100644 index 0000000000..08590bd80e --- /dev/null +++ b/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs @@ -0,0 +1,381 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ShimSkiaSharp.Editing; + +public static class SKPictureEditingExtensions +{ + public static IEnumerable FindCommands(this SKPicture picture) + where TCommand : CanvasCommand + { + if (picture is null) + { + throw new ArgumentNullException(nameof(picture)); + } + + return picture.Commands?.OfType() ?? Enumerable.Empty(); + } + + public static int ReplaceCommands(this SKPicture picture, Func replace) + { + if (picture is null) + { + throw new ArgumentNullException(nameof(picture)); + } + + if (replace is null) + { + throw new ArgumentNullException(nameof(replace)); + } + + var commands = picture.Commands; + if (commands is null) + { + return 0; + } + + var count = 0; + for (var i = 0; i < commands.Count; i++) + { + var original = commands[i]; + var next = replace(original); + if (next is null) + { + commands.RemoveAt(i); + count++; + i--; + continue; + } + + if (!ReferenceEquals(original, next)) + { + commands[i] = next; + count++; + } + } + + return count; + } + + public static int UpdatePaints( + this SKPicture picture, + Func predicate, + Action update, + EditMode mode = EditMode.InPlace) + { + if (picture is null) + { + throw new ArgumentNullException(nameof(picture)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (update is null) + { + throw new ArgumentNullException(nameof(update)); + } + + var commands = picture.Commands; + if (commands is null) + { + return 0; + } + + var count = 0; + var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + if (!TryGetPaint(command, out var paint) || paint is null || !predicate(paint)) + { + continue; + } + + if (mode == EditMode.CloneOnWrite) + { + var cloned = ClonePaint(context!, paint); + if (!ReferenceEquals(cloned, paint)) + { + command = ReplacePaint(command, cloned); + commands[i] = command; + } + + if (visited.Add(paint)) + { + update(cloned); + count++; + } + } + else if (visited.Add(paint)) + { + update(paint); + count++; + } + } + + return count; + } + + public static int UpdatePaths( + this SKPicture picture, + Func predicate, + Action update, + EditMode mode = EditMode.InPlace) + { + if (picture is null) + { + throw new ArgumentNullException(nameof(picture)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (update is null) + { + throw new ArgumentNullException(nameof(update)); + } + + var commands = picture.Commands; + if (commands is null) + { + return 0; + } + + var count = 0; + var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + + for (var i = 0; i < commands.Count; i++) + { + var command = commands[i]; + if (TryGetPath(command, out var path) && path is { } && predicate(path)) + { + if (mode == EditMode.CloneOnWrite) + { + var cloned = ClonePath(context!, path); + if (!ReferenceEquals(cloned, path)) + { + command = ReplacePath(command, cloned); + commands[i] = command; + } + + if (visited.Add(path)) + { + update(cloned); + count++; + } + } + else if (visited.Add(path)) + { + update(path); + count++; + } + } + + if (command is ClipPathCanvasCommand clipCommand && clipCommand.ClipPath is { }) + { + var originalClipPath = clipCommand.ClipPath; + if (!ClipPathContainsMatch(originalClipPath, predicate)) + { + continue; + } + + if (mode == EditMode.CloneOnWrite) + { + var cloned = CloneClipPath(context!, originalClipPath); + if (!ReferenceEquals(cloned, originalClipPath)) + { + command = clipCommand with { ClipPath = cloned }; + commands[i] = command; + } + + count += UpdateClipPathPaths(originalClipPath, cloned, predicate, update, visited); + } + else + { + count += UpdateClipPathPaths(originalClipPath, originalClipPath, predicate, update, visited); + } + } + } + + return count; + } + + private static bool TryGetPaint(CanvasCommand command, out SKPaint? paint) + { + switch (command) + { + case DrawImageCanvasCommand drawImage: + paint = drawImage.Paint; + return true; + case DrawPathCanvasCommand drawPath: + paint = drawPath.Paint; + return true; + case DrawTextBlobCanvasCommand drawTextBlob: + paint = drawTextBlob.Paint; + return true; + case DrawTextCanvasCommand drawText: + paint = drawText.Paint; + return true; + case DrawTextOnPathCanvasCommand drawTextOnPath: + paint = drawTextOnPath.Paint; + return true; + case SaveLayerCanvasCommand saveLayer: + paint = saveLayer.Paint; + return true; + default: + paint = null; + return false; + } + } + + private static CanvasCommand ReplacePaint(CanvasCommand command, SKPaint? paint) + { + return command switch + { + DrawImageCanvasCommand drawImage => drawImage with { Paint = paint }, + DrawPathCanvasCommand drawPath => drawPath with { Paint = paint }, + DrawTextBlobCanvasCommand drawTextBlob => drawTextBlob with { Paint = paint }, + DrawTextCanvasCommand drawText => drawText with { Paint = paint }, + DrawTextOnPathCanvasCommand drawTextOnPath => drawTextOnPath with { Paint = paint }, + SaveLayerCanvasCommand saveLayer => saveLayer with { Paint = paint }, + _ => command + }; + } + + private static bool TryGetPath(CanvasCommand command, out SKPath? path) + { + switch (command) + { + case DrawPathCanvasCommand drawPath: + path = drawPath.Path; + return true; + case DrawTextOnPathCanvasCommand drawTextOnPath: + path = drawTextOnPath.Path; + return true; + default: + path = null; + return false; + } + } + + private static CanvasCommand ReplacePath(CanvasCommand command, SKPath? path) + { + return command switch + { + DrawPathCanvasCommand drawPath => drawPath with { Path = path }, + DrawTextOnPathCanvasCommand drawTextOnPath => drawTextOnPath with { Path = path }, + _ => command + }; + } + + private static SKPaint ClonePaint(CloneContext context, SKPaint paint) + { + if (context.TryGet(paint, out SKPaint existing)) + { + return existing; + } + + return paint.DeepClone(context); + } + + private static SKPath ClonePath(CloneContext context, SKPath path) + { + if (context.TryGet(path, out SKPath existing)) + { + return existing; + } + + return path.DeepClone(context); + } + + private static ClipPath CloneClipPath(CloneContext context, ClipPath clipPath) + { + if (context.TryGet(clipPath, out ClipPath existing)) + { + return existing; + } + + return clipPath.DeepClone(context); + } + + private static int UpdateClipPathPaths( + ClipPath original, + ClipPath target, + Func predicate, + Action update, + HashSet visited) + { + var count = 0; + if (original.Clips is { } originalClips && target.Clips is { }) + { + var targetClips = target.Clips; + var clipCount = Math.Min(originalClips.Count, targetClips.Count); + for (var i = 0; i < clipCount; i++) + { + var originalClip = originalClips[i]; + var targetClip = targetClips[i]; + if (originalClip.Path is { } path && predicate(path)) + { + if (visited.Add(path) && targetClip.Path is { } targetPath) + { + update(targetPath); + count++; + } + } + + if (originalClip.Clip is { } originalNested && targetClip.Clip is { } targetNested) + { + count += UpdateClipPathPaths(originalNested, targetNested, predicate, update, visited); + } + } + } + + if (original.Clip is { } originalNestedClip && target.Clip is { } targetNestedClip) + { + count += UpdateClipPathPaths(originalNestedClip, targetNestedClip, predicate, update, visited); + } + + return count; + } + + private static bool ClipPathContainsMatch(ClipPath clipPath, Func predicate) + { + if (clipPath.Clips is { }) + { + foreach (var pathClip in clipPath.Clips) + { + if (pathClip.Path is { } path && predicate(path)) + { + return true; + } + + if (pathClip.Clip is { } nested && ClipPathContainsMatch(nested, predicate)) + { + return true; + } + } + } + + return clipPath.Clip is { } clip && ClipPathContainsMatch(clip, predicate); + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + + public int GetHashCode(T obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/ShimSkiaSharp/ICanvasCommandVisitor.cs b/src/ShimSkiaSharp/ICanvasCommandVisitor.cs new file mode 100644 index 0000000000..e98dd4d50e --- /dev/null +++ b/src/ShimSkiaSharp/ICanvasCommandVisitor.cs @@ -0,0 +1,20 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; + +namespace ShimSkiaSharp; + +public interface ICanvasCommandVisitor +{ + void Visit(ClipPathCanvasCommand cmd); + void Visit(ClipRectCanvasCommand cmd); + void Visit(DrawImageCanvasCommand cmd); + void Visit(DrawPathCanvasCommand cmd); + void Visit(DrawTextBlobCanvasCommand cmd); + void Visit(DrawTextCanvasCommand cmd); + void Visit(DrawTextOnPathCanvasCommand cmd); + void Visit(RestoreCanvasCommand cmd); + void Visit(SaveCanvasCommand cmd); + void Visit(SaveLayerCanvasCommand cmd); + void Visit(SetMatrixCanvasCommand cmd); +} diff --git a/src/Svg.Model/Editing/DrawableEditingExtensions.cs b/src/Svg.Model/Editing/DrawableEditingExtensions.cs new file mode 100644 index 0000000000..403101a1c9 --- /dev/null +++ b/src/Svg.Model/Editing/DrawableEditingExtensions.cs @@ -0,0 +1,109 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using ShimSkiaSharp; +using ShimSkiaSharp.Editing; +using Svg.Model.Drawables; + +namespace Svg.Model.Editing; + +public static class DrawableEditingExtensions +{ + public static int UpdateFills( + this DrawableBase root, + Func predicate, + Action update, + EditMode mode = EditMode.InPlace) + { + return UpdatePaints(root, predicate, update, mode, drawable => drawable.Fill, (drawable, paint) => drawable.Fill = paint); + } + + public static int UpdateStrokes( + this DrawableBase root, + Func predicate, + Action update, + EditMode mode = EditMode.InPlace) + { + return UpdatePaints(root, predicate, update, mode, drawable => drawable.Stroke, (drawable, paint) => drawable.Stroke = paint); + } + + public static int UpdateOpacity( + this DrawableBase root, + Func predicate, + Action update, + EditMode mode = EditMode.InPlace) + { + return UpdatePaints(root, predicate, update, mode, drawable => drawable.Opacity, (drawable, paint) => drawable.Opacity = paint); + } + + private static int UpdatePaints( + DrawableBase root, + Func predicate, + Action update, + EditMode mode, + Func selector, + Action assign) + { + if (root is null) + { + throw new ArgumentNullException(nameof(root)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (update is null) + { + throw new ArgumentNullException(nameof(update)); + } + + var count = 0; + Dictionary? clones = null; + var visited = new HashSet(ReferenceEqualityComparer.Instance); + + foreach (var drawable in DrawableWalker.Traverse(root)) + { + var paint = selector(drawable); + if (paint is null || !predicate(paint)) + { + continue; + } + + if (mode == EditMode.CloneOnWrite) + { + clones ??= new Dictionary(ReferenceEqualityComparer.Instance); + if (!clones.TryGetValue(paint, out var clone)) + { + clone = paint.DeepClone(); + clones[paint] = clone; + } + + assign(drawable, clone); + if (visited.Add(paint)) + { + update(clone); + count++; + } + } + else if (visited.Add(paint)) + { + update(paint); + count++; + } + } + + return count; + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public bool Equals(SKPaint? x, SKPaint? y) => ReferenceEquals(x, y); + + public int GetHashCode(SKPaint obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/Svg.Model/Editing/DrawableWalker.cs b/src/Svg.Model/Editing/DrawableWalker.cs new file mode 100644 index 0000000000..3740b0cfbb --- /dev/null +++ b/src/Svg.Model/Editing/DrawableWalker.cs @@ -0,0 +1,118 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using Svg.Model.Drawables; +using Svg.Model.Drawables.Elements; + +namespace Svg.Model.Editing; + +public static class DrawableWalker +{ + public static IEnumerable Traverse(DrawableBase root) + { + if (root is null) + { + throw new ArgumentNullException(nameof(root)); + } + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return TraverseIterator(new[] { root }, visited); + } + + public static IEnumerable Traverse(IEnumerable roots) + { + if (roots is null) + { + throw new ArgumentNullException(nameof(roots)); + } + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + return TraverseIterator(roots, visited); + } + + private static IEnumerable TraverseIterator(IEnumerable roots, HashSet visited) + { + var stack = new Stack(); + foreach (var root in roots) + { + if (root is { }) + { + stack.Push(root); + } + } + + while (stack.Count > 0) + { + var current = stack.Pop(); + if (!visited.Add(current)) + { + continue; + } + + yield return current; + + foreach (var child in EnumerateChildren(current)) + { + if (child is { }) + { + stack.Push(child); + } + } + } + } + + private static IEnumerable EnumerateChildren(DrawableBase drawable) + { + if (drawable.MaskDrawable is { } maskDrawable) + { + yield return maskDrawable; + } + + switch (drawable) + { + case DrawableContainer container: + for (var i = container.ChildrenDrawables.Count - 1; i >= 0; i--) + { + yield return container.ChildrenDrawables[i]; + } + break; + case DrawablePath pathDrawable: + if (pathDrawable.MarkerDrawables is { }) + { + for (var i = pathDrawable.MarkerDrawables.Count - 1; i >= 0; i--) + { + yield return pathDrawable.MarkerDrawables[i]; + } + } + break; + case UseDrawable useDrawable: + if (useDrawable.ReferencedDrawable is { } referenced) + { + yield return referenced; + } + break; + case SwitchDrawable switchDrawable: + if (switchDrawable.FirstChild is { } firstChild) + { + yield return firstChild; + } + break; + case MarkerDrawable markerDrawable: + if (markerDrawable.MarkerElementDrawable is { } markerElement) + { + yield return markerElement; + } + break; + } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public bool Equals(DrawableBase? x, DrawableBase? y) => ReferenceEquals(x, y); + + public int GetHashCode(DrawableBase obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/Svg.Model/Editing/SvgDocumentEditingExtensions.cs b/src/Svg.Model/Editing/SvgDocumentEditingExtensions.cs new file mode 100644 index 0000000000..c0d80891ea --- /dev/null +++ b/src/Svg.Model/Editing/SvgDocumentEditingExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; +using Svg; + +namespace Svg.Model.Editing; + +public static class SvgDocumentEditingExtensions +{ + public static IEnumerable TraverseElements(this SvgDocument document) + { + if (document is null) + { + throw new ArgumentNullException(nameof(document)); + } + + var stack = new Stack(); + stack.Push(document); + + while (stack.Count > 0) + { + var current = stack.Pop(); + yield return current; + + if (current.Children is null || current.Children.Count == 0) + { + continue; + } + + for (var i = current.Children.Count - 1; i >= 0; i--) + { + stack.Push(current.Children[i]); + } + } + } + + public static int UpdateStyleAttributes( + this SvgDocument document, + Func predicate, + Action update) + { + if (document is null) + { + throw new ArgumentNullException(nameof(document)); + } + + if (predicate is null) + { + throw new ArgumentNullException(nameof(predicate)); + } + + if (update is null) + { + throw new ArgumentNullException(nameof(update)); + } + + var count = 0; + foreach (var element in document.TraverseElements()) + { + if (element is SvgVisualElement visual && predicate(visual)) + { + update(visual); + count++; + } + } + + return count; + } +} diff --git a/tests/ShimSkiaSharp.UnitTests/EditingHelpersTests.cs b/tests/ShimSkiaSharp.UnitTests/EditingHelpersTests.cs new file mode 100644 index 0000000000..098f41c0a1 --- /dev/null +++ b/tests/ShimSkiaSharp.UnitTests/EditingHelpersTests.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ShimSkiaSharp; +using ShimSkiaSharp.Editing; +using Xunit; + +namespace ShimSkiaSharp.UnitTests; + +public class EditingHelpersTests +{ + [Fact] + public void FindCommands_ReturnsMatchingCommands() + { + var picture = new SKPicture( + SKRect.Create(0, 0, 10, 10), + new List + { + new DrawPathCanvasCommand(CloneTestData.CreatePath(), CloneTestData.CreatePaint()), + new SaveCanvasCommand(1) + }); + + var commands = picture.FindCommands().ToList(); + + Assert.Single(commands); + Assert.IsType(commands[0]); + } + + [Fact] + public void ReplaceCommands_RemovesAndReplaces() + { + var originalPath = CloneTestData.CreatePath(); + var newPath = CloneTestData.CreatePath(); + var commands = new List + { + new DrawTextCanvasCommand("Text", 1, 2, null), + new DrawPathCanvasCommand(originalPath, null) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + + var replaced = picture.ReplaceCommands(command => + command is DrawTextCanvasCommand + ? null + : new DrawPathCanvasCommand(newPath, null)); + + Assert.Equal(2, replaced); + Assert.Single(commands); + var drawPath = Assert.IsType(commands[0]); + Assert.Same(newPath, drawPath.Path); + } + + [Fact] + public void UpdatePaints_InPlace_UpdatesUniquePaints() + { + var sharedPaint = CloneTestData.CreatePaint(); + var commands = new List + { + new DrawPathCanvasCommand(CloneTestData.CreatePath(), sharedPaint), + new DrawImageCanvasCommand(CloneTestData.CreateImage(), SKRect.Create(0, 0, 1, 1), SKRect.Create(0, 0, 1, 1), sharedPaint) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + + var updated = picture.UpdatePaints( + paint => paint.Color is { }, + paint => paint.Color = new SKColor(9, 9, 9, 9)); + + Assert.Equal(1, updated); + Assert.Equal(new SKColor(9, 9, 9, 9), sharedPaint.Color); + Assert.Same(sharedPaint, ((DrawPathCanvasCommand)commands[0]).Paint); + } + + [Fact] + public void UpdatePaints_CloneOnWrite_SharedPaintUsesClone() + { + var sharedPaint = CloneTestData.CreatePaint(); + var commands = new List + { + new DrawPathCanvasCommand(CloneTestData.CreatePath(), sharedPaint), + new DrawPathCanvasCommand(CloneTestData.CreatePath(), sharedPaint) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + + var updated = picture.UpdatePaints( + paint => paint.Color is { }, + paint => paint.Color = new SKColor(7, 7, 7, 7), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + var first = ((DrawPathCanvasCommand)commands[0]).Paint!; + var second = ((DrawPathCanvasCommand)commands[1]).Paint!; + Assert.NotSame(sharedPaint, first); + Assert.Same(first, second); + Assert.Equal(new SKColor(1, 2, 3, 4), sharedPaint.Color); + Assert.Equal(new SKColor(7, 7, 7, 7), first.Color); + } + + [Fact] + public void UpdatePaints_CloneOnWrite_LeavesNonMatchingPaints() + { + var paintA = CloneTestData.CreatePaint(); + var paintB = CloneTestData.CreatePaint(); + var commands = new List + { + new DrawPathCanvasCommand(CloneTestData.CreatePath(), paintA), + new DrawPathCanvasCommand(CloneTestData.CreatePath(), paintB) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + + var updated = picture.UpdatePaints( + paint => ReferenceEquals(paint, paintA), + paint => paint.Color = new SKColor(4, 4, 4, 4), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + var updatedPaint = ((DrawPathCanvasCommand)commands[0]).Paint!; + var untouchedPaint = ((DrawPathCanvasCommand)commands[1]).Paint!; + Assert.NotSame(paintA, updatedPaint); + Assert.Same(paintB, untouchedPaint); + } + + [Fact] + public void UpdatePaths_InPlace_UpdatesUniquePaths() + { + var sharedPath = CloneTestData.CreatePath(); + var commands = new List + { + new DrawPathCanvasCommand(sharedPath, null), + new DrawTextOnPathCanvasCommand("Text", sharedPath, 0, 0, null) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + var originalCount = sharedPath.Commands!.Count; + + var updated = picture.UpdatePaths( + path => ReferenceEquals(path, sharedPath), + path => path.LineTo(9, 9)); + + Assert.Equal(1, updated); + Assert.Equal(originalCount + 1, sharedPath.Commands!.Count); + Assert.Same(sharedPath, ((DrawPathCanvasCommand)commands[0]).Path); + } + + [Fact] + public void UpdatePaths_CloneOnWrite_ClonesSharedPath() + { + var sharedPath = CloneTestData.CreatePath(); + var commands = new List + { + new DrawPathCanvasCommand(sharedPath, null), + new DrawPathCanvasCommand(sharedPath, null) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + var originalCount = sharedPath.Commands!.Count; + + var updated = picture.UpdatePaths( + path => ReferenceEquals(path, sharedPath), + path => path.LineTo(9, 9), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + var first = ((DrawPathCanvasCommand)commands[0]).Path!; + var second = ((DrawPathCanvasCommand)commands[1]).Path!; + Assert.NotSame(sharedPath, first); + Assert.Same(first, second); + Assert.Equal(originalCount, sharedPath.Commands!.Count); + Assert.Equal(originalCount + 1, first.Commands!.Count); + } + + [Fact] + public void UpdatePaths_CloneOnWrite_UpdatesClipPaths() + { + var clipPath = CloneTestData.CreateClipPath(); + var commands = new List + { + new ClipPathCanvasCommand(clipPath, SKClipOperation.Intersect, false) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + + var updated = picture.UpdatePaths( + path => path.Commands is { Count: > 0 }, + path => path.LineTo(5, 5), + EditMode.CloneOnWrite); + + var updatedClip = ((ClipPathCanvasCommand)commands[0]).ClipPath!; + Assert.NotSame(clipPath, updatedClip); + Assert.Equal(2, updated); + } + + [Fact] + public void UpdatePaths_CloneOnWrite_SharedPathUpdatesOnce() + { + var sharedPath = CloneTestData.CreatePath(); + var clipPath = new ClipPath(); + clipPath.Clips!.Add(new PathClip { Path = sharedPath }); + var commands = new List + { + new DrawPathCanvasCommand(sharedPath, null), + new ClipPathCanvasCommand(clipPath, SKClipOperation.Intersect, false) + }; + var picture = new SKPicture(SKRect.Create(0, 0, 10, 10), commands); + var originalCount = sharedPath.Commands!.Count; + + var updated = picture.UpdatePaths( + path => ReferenceEquals(path, sharedPath), + path => path.LineTo(5, 5), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + var updatedDrawPath = ((DrawPathCanvasCommand)commands[0]).Path!; + var updatedClipPath = ((ClipPathCanvasCommand)commands[1]).ClipPath!; + var updatedClipPathPath = updatedClipPath.Clips![0].Path!; + Assert.NotSame(sharedPath, updatedDrawPath); + Assert.Same(updatedDrawPath, updatedClipPathPath); + Assert.Equal(originalCount + 1, updatedDrawPath.Commands!.Count); + Assert.Equal(originalCount, sharedPath.Commands!.Count); + } + + [Fact] + public void PathUpdateCommands_ReplacesMatchingCommands() + { + var path = new SKPath(); + path.MoveTo(1, 2); + path.LineTo(3, 4); + + var updated = path.UpdateCommands( + command => command is MoveToPathCommand, + command => new MoveToPathCommand(9, 10)); + + Assert.Equal(1, updated); + var move = Assert.IsType(path.Commands![0]); + Assert.Equal(9, move.X); + Assert.Equal(10, move.Y); + } + + [Fact] + public void PathTransform_MapsCommands() + { + var path = new SKPath(); + path.MoveTo(1, 2); + path.LineTo(3, 4); + path.AddCircle(1, 1, 2); + + var matrix = SKMatrix.CreateTranslation(2, 3); + path.Transform(matrix); + + var move = Assert.IsType(path.Commands![0]); + var line = Assert.IsType(path.Commands![1]); + var circle = Assert.IsType(path.Commands![2]); + Assert.Equal(3, move.X); + Assert.Equal(5, move.Y); + Assert.Equal(5, line.X); + Assert.Equal(7, line.Y); + Assert.Equal(3, circle.X); + Assert.Equal(4, circle.Y); + } + + [Fact] + public void PathTransform_ConvertsCircleToOvalOnNonUniformScale() + { + var path = new SKPath(); + path.AddCircle(1, 1, 2); + + var matrix = SKMatrix.CreateScale(2, 3); + path.Transform(matrix); + + Assert.IsType(path.Commands![0]); + } + + [Fact] + public void PathTransform_ThrowsOnRotation() + { + var path = new SKPath(); + path.MoveTo(1, 2); + + Assert.Throws(() => path.Transform(SKMatrix.CreateRotationDegrees(90))); + } + + [Fact] + public void PathTransform_FlipsArcSweepOnReflection() + { + var path = new SKPath(); + path.ArcTo(2, 3, 0, SKPathArcSize.Small, SKPathDirection.Clockwise, 4, 5); + + path.Transform(SKMatrix.CreateScale(-1, 1)); + + var arc = Assert.IsType(path.Commands![0]); + Assert.Equal(SKPathDirection.CounterClockwise, arc.Sweep); + } + + [Fact] + public void ApplyColorTransform_UpdatesColor() + { + var paint = CloneTestData.CreatePaint(); + + paint.ApplyColorTransform(color => new SKColor(8, 8, 8, color.Alpha)); + + Assert.Equal(new SKColor(8, 8, 8, 4), paint.Color); + } + + [Fact] + public void ApplyShaderTransform_UpdatesShader() + { + var paint = CloneTestData.CreatePaint(); + var newShader = SKShader.CreateColor(new SKColor(9, 9, 9, 9), SKColorSpace.Srgb); + + paint.ApplyShaderTransform(_ => newShader); + + Assert.Same(newShader, paint.Shader); + } + + [Fact] + public void CanvasCommandVisitor_VisitsExpectedCommand() + { + var visited = new List(); + var visitor = new TrackingVisitor(visited); + var command = new DrawTextCanvasCommand("Text", 1, 2, null); + + command.Accept(visitor); + + Assert.Single(visited); + Assert.Equal(nameof(DrawTextCanvasCommand), visited[0]); + } + + private sealed class TrackingVisitor : ICanvasCommandVisitor + { + private readonly IList _visited; + + public TrackingVisitor(IList visited) => _visited = visited; + + public void Visit(ClipPathCanvasCommand cmd) => _visited.Add(nameof(ClipPathCanvasCommand)); + public void Visit(ClipRectCanvasCommand cmd) => _visited.Add(nameof(ClipRectCanvasCommand)); + public void Visit(DrawImageCanvasCommand cmd) => _visited.Add(nameof(DrawImageCanvasCommand)); + public void Visit(DrawPathCanvasCommand cmd) => _visited.Add(nameof(DrawPathCanvasCommand)); + public void Visit(DrawTextBlobCanvasCommand cmd) => _visited.Add(nameof(DrawTextBlobCanvasCommand)); + public void Visit(DrawTextCanvasCommand cmd) => _visited.Add(nameof(DrawTextCanvasCommand)); + public void Visit(DrawTextOnPathCanvasCommand cmd) => _visited.Add(nameof(DrawTextOnPathCanvasCommand)); + public void Visit(RestoreCanvasCommand cmd) => _visited.Add(nameof(RestoreCanvasCommand)); + public void Visit(SaveCanvasCommand cmd) => _visited.Add(nameof(SaveCanvasCommand)); + public void Visit(SaveLayerCanvasCommand cmd) => _visited.Add(nameof(SaveLayerCanvasCommand)); + public void Visit(SetMatrixCanvasCommand cmd) => _visited.Add(nameof(SetMatrixCanvasCommand)); + } +} diff --git a/tests/Svg.Model.UnitTests/EditingHelpersTests.cs b/tests/Svg.Model.UnitTests/EditingHelpersTests.cs new file mode 100644 index 0000000000..f97b2c8434 --- /dev/null +++ b/tests/Svg.Model.UnitTests/EditingHelpersTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using ShimSkiaSharp; +using ShimSkiaSharp.Editing; +using Svg; +using Svg.Model; +using Svg.Model.Drawables; +using Svg.Model.Drawables.Elements; +using Svg.Model.Editing; +using Svg.Model.Services; +using Xunit; + +namespace Svg.Model.UnitTests; + +public class EditingHelpersTests +{ + private static readonly ISvgAssetLoader s_assetLoader = new TestAssetLoader(); + + [Fact] + public void DrawableWalker_Traverse_ReturnsAllNodes() + { + var root = new TestContainer(s_assetLoader, DrawableCloneTestData.CreateReferences()); + var child = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()); + root.ChildrenDrawables.Add(child); + + var useDrawable = CreateDrawable(); + useDrawable.ReferencedDrawable = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()); + root.ChildrenDrawables.Add(useDrawable); + + var switchDrawable = CreateDrawable(); + switchDrawable.FirstChild = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()); + root.ChildrenDrawables.Add(switchDrawable); + + var markerDrawable = CreateDrawable(); + markerDrawable.MarkerElementDrawable = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()); + root.ChildrenDrawables.Add(markerDrawable); + + var pathDrawable = CreateDrawable(); + var pathMarker = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()); + pathDrawable.MarkerDrawables = new List { pathMarker }; + root.ChildrenDrawables.Add(pathDrawable); + + var maskDrawable = CreateDrawable(); + maskDrawable.ChildrenDrawables.Add(new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences())); + root.MaskDrawable = maskDrawable; + + var nodes = DrawableWalker.Traverse(root).ToList(); + + Assert.Equal(12, nodes.Count); + Assert.Contains(root, nodes); + Assert.Contains(child, nodes); + Assert.Contains(useDrawable, nodes); + Assert.Contains(useDrawable.ReferencedDrawable!, nodes); + Assert.Contains(switchDrawable, nodes); + Assert.Contains(switchDrawable.FirstChild!, nodes); + Assert.Contains(markerDrawable, nodes); + Assert.Contains(markerDrawable.MarkerElementDrawable!, nodes); + Assert.Contains(pathDrawable, nodes); + Assert.Contains(pathMarker, nodes); + Assert.Contains(maskDrawable, nodes); + Assert.Contains(maskDrawable.ChildrenDrawables[0], nodes); + } + + [Fact] + public void UpdateFills_CloneOnWrite_ClonesSharedPaint() + { + var root = new TestContainer(s_assetLoader, DrawableCloneTestData.CreateReferences()); + var sharedPaint = DrawableCloneTestData.CreatePaint(200); + var childA = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Fill = sharedPaint }; + var childB = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Fill = sharedPaint }; + root.ChildrenDrawables.Add(childA); + root.ChildrenDrawables.Add(childB); + + var updated = root.UpdateFills( + paint => paint.Color is { }, + paint => paint.Color = new SKColor(1, 1, 1, 1), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + Assert.NotSame(sharedPaint, childA.Fill); + Assert.Same(childA.Fill, childB.Fill); + Assert.Equal(new SKColor(10, 20, 30, 200), sharedPaint.Color); + } + + [Fact] + public void UpdateStrokes_InPlace_UpdatesUniquePaints() + { + var root = new TestContainer(s_assetLoader, DrawableCloneTestData.CreateReferences()); + var paint = DrawableCloneTestData.CreatePaint(100); + var childA = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Stroke = paint }; + var childB = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Stroke = paint }; + root.ChildrenDrawables.Add(childA); + root.ChildrenDrawables.Add(childB); + + var updated = root.UpdateStrokes( + p => p.Color is { }, + p => p.Color = new SKColor(2, 2, 2, 2)); + + Assert.Equal(1, updated); + Assert.Equal(new SKColor(2, 2, 2, 2), paint.Color); + } + + [Fact] + public void UpdateOpacity_CloneOnWrite_UpdatesTargetOnly() + { + var root = new TestContainer(s_assetLoader, DrawableCloneTestData.CreateReferences()); + var paintA = DrawableCloneTestData.CreatePaint(10); + var paintB = DrawableCloneTestData.CreatePaint(20); + var childA = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Opacity = paintA }; + var childB = new TestDrawable(s_assetLoader, DrawableCloneTestData.CreateReferences()) { Opacity = paintB }; + root.ChildrenDrawables.Add(childA); + root.ChildrenDrawables.Add(childB); + + var updated = root.UpdateOpacity( + p => ReferenceEquals(p, paintA), + p => p.Color = new SKColor(9, 9, 9, 9), + EditMode.CloneOnWrite); + + Assert.Equal(1, updated); + Assert.NotSame(paintA, childA.Opacity); + Assert.Same(paintB, childB.Opacity); + } + + [Fact] + public void SvgDocumentTraverseElements_ReturnsAllNodes() + { + var svg = ""; + var document = SvgService.FromSvg(svg); + Assert.NotNull(document); + + var elements = document!.TraverseElements().ToList(); + + Assert.Equal(4, elements.Count); + Assert.Contains(elements, element => element.ID == "group"); + Assert.Contains(elements, element => element.ID == "rect"); + Assert.Contains(elements, element => element.ID == "circle"); + } + + [Fact] + public void UpdateStyleAttributes_UpdatesMatchingElements() + { + var svg = ""; + var document = SvgService.FromSvg(svg); + Assert.NotNull(document); + + var updated = document!.UpdateStyleAttributes( + element => element.ID == "b", + element => element.Visibility = "hidden"); + + Assert.Equal(1, updated); + var target = document.Children.OfType().First(e => e.ID == "b"); + Assert.Equal("hidden", target.Visibility); + } + + private static T CreateDrawable() where T : DrawableBase + { + var type = typeof(T); + var ctor = type.GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + new[] { typeof(ISvgAssetLoader), typeof(HashSet) }, + modifiers: null); + Assert.NotNull(ctor); + return (T)ctor!.Invoke(new object?[] { s_assetLoader, DrawableCloneTestData.CreateReferences() }); + } + + private sealed class TestDrawable : DrawableBase + { + public TestDrawable(ISvgAssetLoader assetLoader, HashSet? references) + : base(assetLoader, references) + { + } + + public override void OnDraw(SKCanvas canvas, DrawAttributes ignoreAttributes, DrawableBase? until) + { + } + + public override SKDrawable Clone() + { + var clone = new TestDrawable(AssetLoader, CloneReferences(References)); + CopyTo(clone, Parent); + return clone; + } + } + + private sealed class TestContainer : DrawableContainer + { + public TestContainer(ISvgAssetLoader assetLoader, HashSet? references) + : base(assetLoader, references) + { + } + + public override SKDrawable Clone() + { + var clone = new TestContainer(AssetLoader, CloneReferences(References)); + CopyTo(clone, Parent); + return clone; + } + } +}