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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ If you want to modify the generated draw commands, update the model and rebuild
```csharp
using System.Linq;
using ShimSkiaSharp;
using ShimSkiaSharp.Editing;
using Svg.Skia;

var skSvg = new SKSvg();
Expand All @@ -429,12 +430,27 @@ foreach (var cmd in skSvg.Model?.Commands?.OfType<DrawPathCanvasCommand>() ?? En
skSvg.RebuildFromModel();
```

Commands produced from SVG elements include source metadata. Use the source element id or address to update only the commands that came from a specific element:

```csharp
foreach (var cmd in skSvg.Model?.FindCommandsBySourceElementId<DrawPathCanvasCommand>("target-path") ?? Enumerable.Empty<DrawPathCanvasCommand>())
{
if (cmd.Paint?.Color is { } color)
{
cmd.Paint.Color = new SKColor(0, 128, 0, color.Alpha);
}
}

skSvg.RebuildFromModel();
```

The same rebuild flow is available on Avalonia sources:

```csharp
using System.Linq;
using Avalonia.Svg.Skia;
using ShimSkiaSharp;
using ShimSkiaSharp.Editing;

var source = SvgSource.Load("avares://MyAssembly/Assets/Icon.svg", baseUri: null);

Expand Down
44 changes: 44 additions & 0 deletions src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,50 @@ public static IEnumerable<TCommand> FindCommands<TCommand>(this SKPicture pictur
return EnumerateCommands(picture).OfType<TCommand>();
}

public static IEnumerable<CanvasCommand> FindCommandsBySourceElementId(this SKPicture picture, string sourceElementId)
{
if (picture is null)
{
throw new ArgumentNullException(nameof(picture));
}

if (string.IsNullOrWhiteSpace(sourceElementId))
{
throw new ArgumentException("Source element id must not be empty.", nameof(sourceElementId));
}

return EnumerateCommands(picture)
.Where(command => string.Equals(command.SourceElementId, sourceElementId, StringComparison.Ordinal));
}

public static IEnumerable<TCommand> FindCommandsBySourceElementId<TCommand>(this SKPicture picture, string sourceElementId)
where TCommand : CanvasCommand
{
return FindCommandsBySourceElementId(picture, sourceElementId).OfType<TCommand>();
}

public static IEnumerable<CanvasCommand> FindCommandsBySourceElementAddress(this SKPicture picture, string sourceElementAddress)
{
if (picture is null)
{
throw new ArgumentNullException(nameof(picture));
}

if (string.IsNullOrWhiteSpace(sourceElementAddress))
{
throw new ArgumentException("Source element address must not be empty.", nameof(sourceElementAddress));
}

return EnumerateCommands(picture)
.Where(command => string.Equals(command.SourceElementAddress, sourceElementAddress, StringComparison.Ordinal));
}

public static IEnumerable<TCommand> FindCommandsBySourceElementAddress<TCommand>(this SKPicture picture, string sourceElementAddress)
where TCommand : CanvasCommand
{
return FindCommandsBySourceElementAddress(picture, sourceElementAddress).OfType<TCommand>();
}

public static int ReplaceCommands(this SKPicture picture, Func<CanvasCommand, CanvasCommand?> replace)
{
if (picture is null)
Expand Down
124 changes: 112 additions & 12 deletions src/ShimSkiaSharp/SKCanvas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ namespace ShimSkiaSharp;

public abstract record CanvasCommand : IDeepCloneable<CanvasCommand>
{
public string? SourceElementId { get; set; }

public string? SourceElementAddress { get; set; }

public string? SourceElementTypeName { get; set; }

public CanvasCommand DeepClone() => DeepClone(new CloneContext());

internal CanvasCommand DeepClone(CloneContext context)
Expand Down Expand Up @@ -37,6 +43,7 @@ internal CanvasCommand DeepClone(CloneContext context)
_ => throw new NotSupportedException($"Unsupported {nameof(CanvasCommand)} type: {GetType().Name}.")
};

CopySourceMetadataTo(clone);
context.Add(this, clone);
return clone;
}
Expand All @@ -45,6 +52,13 @@ internal CanvasCommand DeepClone(CloneContext context)
context.Exit(this);
}
}

internal void CopySourceMetadataTo(CanvasCommand command)
{
command.SourceElementId = SourceElementId;
command.SourceElementAddress = SourceElementAddress;
command.SourceElementTypeName = SourceElementTypeName;
}
}

public record ClipPathCanvasCommand(ClipPath? ClipPath, SKClipOperation Operation, bool Antialias) : CanvasCommand;
Expand Down Expand Up @@ -73,8 +87,50 @@ public record SetMatrixCanvasCommand(SKMatrix DeltaMatrix, SKMatrix TotalMatrix)

public class SKCanvas : ICloneable, IDeepCloneable<SKCanvas>
{
private readonly struct CommandSourceState
{
public CommandSourceState(string? elementId, string? elementAddress, string? elementTypeName)
{
ElementId = elementId;
ElementAddress = elementAddress;
ElementTypeName = elementTypeName;
}

public string? ElementId { get; }

public string? ElementAddress { get; }

public string? ElementTypeName { get; }
}

private sealed class CommandSourceScope : IDisposable
{
private SKCanvas? _canvas;

public CommandSourceScope(SKCanvas canvas)
{
_canvas = canvas;
}

public void Dispose()
{
var canvas = _canvas;
if (canvas is null)
{
return;
}

_canvas = null;
canvas.PopCommandSource();
}
}

private int _saveCount;
private readonly Stack<SKMatrix> _totalMatrices = new();
private readonly Stack<CommandSourceState> _commandSources = new();
private string? _commandSourceElementId;
private string? _commandSourceElementAddress;
private string? _commandSourceElementTypeName;

public IList<CanvasCommand>? Commands { get; }

Expand Down Expand Up @@ -121,64 +177,78 @@ internal SKCanvas DeepClone(CloneContext context)
return clone;
}

public IDisposable PushCommandSource(string? elementId, string? elementAddress, string? elementTypeName)
{
_commandSources.Push(new CommandSourceState(
_commandSourceElementId,
_commandSourceElementAddress,
_commandSourceElementTypeName));

_commandSourceElementId = elementId;
_commandSourceElementAddress = elementAddress;
_commandSourceElementTypeName = elementTypeName;

return new CommandSourceScope(this);
}

public void ClipPath(ClipPath clipPath, SKClipOperation operation = SKClipOperation.Intersect, bool antialias = false)
{
Commands?.Add(new ClipPathCanvasCommand(clipPath, operation, antialias));
AddCommand(new ClipPathCanvasCommand(clipPath, operation, antialias));
}

public void ClipRect(SKRect rect, SKClipOperation operation = SKClipOperation.Intersect, bool antialias = false)
{
Commands?.Add(new ClipRectCanvasCommand(rect, operation, antialias));
AddCommand(new ClipRectCanvasCommand(rect, operation, antialias));
}

public void DrawImage(SKImage image, SKRect source, SKRect dest, SKPaint? paint = null)
{
Commands?.Add(new DrawImageCanvasCommand(image, source, dest, paint));
AddCommand(new DrawImageCanvasCommand(image, source, dest, paint));
}

public void DrawPicture(SKPicture picture)
{
Commands?.Add(new DrawPictureCanvasCommand(picture));
AddCommand(new DrawPictureCanvasCommand(picture));
}

public void DrawPath(SKPath path, SKPaint paint)
{
Commands?.Add(new DrawPathCanvasCommand(path, paint));
AddCommand(new DrawPathCanvasCommand(path, paint));
}

public void DrawText(SKTextBlob textBlob, float x, float y, SKPaint paint)
{
Commands?.Add(new DrawTextBlobCanvasCommand(textBlob, x, y, paint));
AddCommand(new DrawTextBlobCanvasCommand(textBlob, x, y, paint));
}

public void DrawText(string text, float x, float y, SKPaint paint)
{
Commands?.Add(new DrawTextCanvasCommand(text, x, y, paint));
AddCommand(new DrawTextCanvasCommand(text, x, y, paint));
}

public void DrawTextOnPath(string text, SKPath path, float hOffset, float vOffset, SKPaint paint)
{
Commands?.Add(new DrawTextOnPathCanvasCommand(text, path, hOffset, vOffset, paint));
AddCommand(new DrawTextOnPathCanvasCommand(text, path, hOffset, vOffset, paint));
}

public void SetMatrix(SKMatrix deltaMatrix)
{
TotalMatrix = TotalMatrix.PreConcat(deltaMatrix);
Commands?.Add(new SetMatrixCanvasCommand(deltaMatrix, TotalMatrix));
AddCommand(new SetMatrixCanvasCommand(deltaMatrix, TotalMatrix));
}

public int Save()
{
_totalMatrices.Push(TotalMatrix);
Commands?.Add(new SaveCanvasCommand(_saveCount));
AddCommand(new SaveCanvasCommand(_saveCount));
_saveCount++;
return _saveCount;
}

public int SaveLayer(SKPaint paint)
{
_totalMatrices.Push(TotalMatrix);
Commands?.Add(new SaveLayerCanvasCommand(_saveCount, paint));
AddCommand(new SaveLayerCanvasCommand(_saveCount, paint));
_saveCount++;
return _saveCount;
}
Expand All @@ -195,6 +265,36 @@ public void Restore()
_saveCount--;
}

Commands?.Add(new RestoreCanvasCommand(_saveCount));
AddCommand(new RestoreCanvasCommand(_saveCount));
}

private void AddCommand(CanvasCommand command)
{
if (_commandSourceElementId is not null ||
_commandSourceElementAddress is not null ||
_commandSourceElementTypeName is not null)
{
command.SourceElementId = _commandSourceElementId;
command.SourceElementAddress = _commandSourceElementAddress;
command.SourceElementTypeName = _commandSourceElementTypeName;
}

Commands?.Add(command);
}

private void PopCommandSource()
{
if (_commandSources.Count == 0)
{
_commandSourceElementId = null;
_commandSourceElementAddress = null;
_commandSourceElementTypeName = null;
return;
}

var state = _commandSources.Pop();
_commandSourceElementId = state.ElementId;
_commandSourceElementAddress = state.ElementAddress;
_commandSourceElementTypeName = state.ElementTypeName;
}
}
42 changes: 42 additions & 0 deletions src/Svg.SceneGraph/SvgSceneRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ internal static bool RenderNodeToCanvas(
return true;
}

using var commandSource = canvas.PushCommandSource(
node.ElementId,
node.ElementAddressKey,
node.ElementTypeName);

var enableClip = !ignoreAttributes.HasFlag(DrawAttributes.ClipPath);
var enableMask = !ignoreAttributes.HasFlag(DrawAttributes.Mask) && !ignoreCurrentMask;
var enableOpacity = !ignoreAttributes.HasFlag(DrawAttributes.Opacity) && !ignoreCurrentOpacity;
Expand Down Expand Up @@ -221,6 +226,11 @@ private static bool RenderBackgroundToCanvasCore(

var isOnUntilPath = IsSelfOrAncestor(node, until);

using var commandSource = canvas.PushCommandSource(
node.ElementId,
node.ElementAddressKey,
node.ElementTypeName);

canvas.Save();

if (node.Overflow is { } overflow)
Expand Down Expand Up @@ -318,6 +328,7 @@ internal static void DrawNodeLocalVisuals(SvgSceneNode node, SKCanvas canvas)
{
if (node.LocalModel is { } localModel)
{
ApplySourceMetadata(localModel, node, overwrite: false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve source metadata for text child runs

When LocalModel is a text model, it can contain draw commands for multiple child text elements such as <tspan id="a"> and <tspan id="b">; SvgSceneTextCompiler records those runs directly from each run's StyleSource, but the recorder never pushes per-run source metadata. Backfilling the whole local model with the outer node here means FindCommandsBySourceElementId("a") returns nothing, or all commands are attributed to the parent <text> instead of the child element, which breaks the advertised element-targeted editing for common text SVGs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3db12bb.

This now records source metadata at the text-run level while SvgSceneTextCompiler records local text-model pictures. The compiler pushes the resolved SvgTextBase or parent anchor source around each run, using the same element-address callback as the scene compiler, so commands from child runs such as <tspan id="a"> and <tspan id="b"> carry their own SourceElementId, address, and type. The existing renderer backfill remains a fallback only (overwrite: false), so parent <text> metadata no longer replaces metadata already attached to child-run commands.

I also added regression coverage in SKSvgRebuildFromModelTests.ModelCommands_PreserveChildTextRunSourceMetadata: it checks that FindCommandsBySourceElementId("a") and "b" return the correct DrawTextCanvasCommand text, that the commands are typed as SvgTextSpan, and that text-root does not claim the child run draw commands.

Verification:

  • dotnet format Svg.Skia.slnx --no-restore --exclude externals
  • dotnet build Svg.Skia.slnx -c Release
  • dotnet test Svg.Skia.slnx -c Release

canvas.DrawPicture(localModel);
return;
}
Expand All @@ -338,6 +349,37 @@ internal static void DrawNodeLocalVisuals(SvgSceneNode node, SKCanvas canvas)
}
}

internal static void ApplySourceMetadata(SKPicture picture, SvgSceneNode node, bool overwrite)
{
if (picture.Commands is null)
{
return;
}

for (var i = 0; i < picture.Commands.Count; i++)
{
ApplySourceMetadata(picture.Commands[i], node, overwrite);
}
}

private static void ApplySourceMetadata(CanvasCommand command, SvgSceneNode node, bool overwrite)
{
if (overwrite ||
(command.SourceElementId is null &&
command.SourceElementAddress is null &&
command.SourceElementTypeName is null))
{
command.SourceElementId = node.ElementId;
command.SourceElementAddress = node.ElementAddressKey;
command.SourceElementTypeName = node.ElementTypeName;
}

if (command is DrawPictureCanvasCommand { Picture: { } nestedPicture })
{
ApplySourceMetadata(nestedPicture, node, overwrite);
}
}

private static bool RenderChildrenToCanvas(
SvgSceneDocument sceneDocument,
SvgSceneNode node,
Expand Down
Loading
Loading