Browse Source

Basic svg raster saving

flabbet 11 months ago
parent
commit
63bbd19e93
28 changed files with 348 additions and 75 deletions
  1. 1 1
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FolderNode.cs
  2. 10 4
      src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs
  3. 1 1
      src/PixiEditor.SVG/Elements/SvgCircle.cs
  4. 1 1
      src/PixiEditor.SVG/Elements/SvgEllipse.cs
  5. 1 1
      src/PixiEditor.SVG/Elements/SvgGroup.cs
  6. 19 0
      src/PixiEditor.SVG/Elements/SvgImage.cs
  7. 1 1
      src/PixiEditor.SVG/Elements/SvgLine.cs
  8. 1 1
      src/PixiEditor.SVG/Elements/SvgPolyline.cs
  9. 1 1
      src/PixiEditor.SVG/Elements/SvgPrimitive.cs
  10. 1 1
      src/PixiEditor.SVG/Elements/SvgRectangle.cs
  11. 6 0
      src/PixiEditor.SVG/Features/IElementContainer.cs
  12. 52 5
      src/PixiEditor.SVG/SvgDocument.cs
  13. 45 3
      src/PixiEditor.SVG/SvgElement.cs
  14. 4 4
      src/PixiEditor.SVG/SvgProperty.cs
  15. 5 0
      src/PixiEditor.SVG/Units/SvgColorUnit.cs
  16. 10 0
      src/PixiEditor.SVG/Units/SvgNumericUnit.cs
  17. 4 0
      src/PixiEditor.SVG/Units/SvgStringUnit.cs
  18. 4 0
      src/PixiEditor.SVG/Units/SvgTransform.cs
  19. 1 1
      src/PixiEditor.SVG/Units/SvgUnit.cs
  20. 1 0
      src/PixiEditor/Helpers/ServiceCollectionHelpers.cs
  21. 35 0
      src/PixiEditor/Models/Files/SvgFileType.cs
  22. 0 14
      src/PixiEditor/Models/Files/VectorFileType.cs
  23. 24 9
      src/PixiEditor/Models/IO/Exporter.cs
  24. 1 0
      src/PixiEditor/PixiEditor.csproj
  25. 102 14
      src/PixiEditor/ViewModels/Document/DocumentViewModel.Serialization.cs
  26. 4 1
      src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs
  27. 2 2
      src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs
  28. 11 10
      src/PixiEditor/Views/Dialogs/ProgressDialog.cs

+ 1 - 1
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/FolderNode.cs

@@ -111,7 +111,7 @@ public class FolderNode : StructureNode, IReadOnlyFolderNode
             {
                 if (n is ImageLayerNode imageLayerNode)
                 {
-                    RectI? imageBounds = (RectI)imageLayerNode.GetTightBounds(frameTime);
+                    RectI? imageBounds = (RectI?)imageLayerNode.GetTightBounds(frameTime);
                     if (imageBounds != null)
                     {
                         bounds = bounds.Union(imageBounds.Value);

+ 10 - 4
src/PixiEditor.ChangeableDocument/Rendering/DocumentRenderer.cs

@@ -66,7 +66,7 @@ public class DocumentRenderer
 
         return toDrawOn;
     }
-
+    
     public OneOf<Chunk, EmptyChunk> RenderChunk(VecI chunkPos, ChunkResolution resolution, KeyFrameTime frameTime,
         RectI? globalClippingRect = null)
     {
@@ -125,7 +125,7 @@ public class DocumentRenderer
         HashSet<Guid> layersToCombine, RectI? globalClippingRect)
     {
         using RenderingContext context = new(frame, chunkPos, resolution, Document.Size);
-        NodeGraph membersOnlyGraph = ConstructMembersOnlyGraph(layersToCombine, Document.NodeGraph);
+        IReadOnlyNodeGraph membersOnlyGraph = ConstructMembersOnlyGraph(layersToCombine, Document.NodeGraph);
         try
         {
             return RenderChunkOnGraph(chunkPos, resolution, globalClippingRect, membersOnlyGraph, context);
@@ -187,7 +187,13 @@ public class DocumentRenderer
         return chunk;
     }
 
-    private NodeGraph ConstructMembersOnlyGraph(HashSet<Guid> layersToCombine, IReadOnlyNodeGraph fullGraph)
+    public static IReadOnlyNodeGraph ConstructMembersOnlyGraph(IReadOnlyNodeGraph fullGraph)
+    {
+        return ConstructMembersOnlyGraph(null, fullGraph); 
+    }
+
+    public static IReadOnlyNodeGraph ConstructMembersOnlyGraph(HashSet<Guid>? layersToCombine,
+        IReadOnlyNodeGraph fullGraph)
     {
         NodeGraph membersOnlyGraph = new();
 
@@ -199,7 +205,7 @@ public class DocumentRenderer
 
         fullGraph.TryTraverse(node =>
         {
-            if (node is LayerNode layer && layersToCombine.Contains(layer.Id))
+            if (node is LayerNode layer && (layersToCombine == null || layersToCombine.Contains(layer.Id)))
             {
                 layersInOrder.Insert(0, layer);
             }

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgCircle.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgCircle : SvgPrimitive
+public class SvgCircle() : SvgPrimitive("circle")
 {
     public SvgProperty<SvgNumericUnit> Cx { get; } = new("cx");
     public SvgProperty<SvgNumericUnit> Cy { get; } = new("cy");

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgEllipse.cs

@@ -3,7 +3,7 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgEllipse : SvgPrimitive 
+public class SvgEllipse() : SvgPrimitive("ellipse")
 {
     public SvgProperty<SvgNumericUnit> Cx { get; } = new("cx");
     public SvgProperty<SvgNumericUnit> Cy { get; } = new("cy"); 

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgG.cs → src/PixiEditor.SVG/Elements/SvgGroup.cs

@@ -3,7 +3,7 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgG : SvgElement, ITransformable, IFillable, IStrokable
+public class SvgGroup() : SvgElement("g"), ITransformable, IFillable, IStrokable, IElementContainer
 {
     public List<SvgElement> Children { get; } = new();
     public SvgProperty<SvgTransform> Transform { get; } = new("transform");

+ 19 - 0
src/PixiEditor.SVG/Elements/SvgImage.cs

@@ -0,0 +1,19 @@
+using PixiEditor.SVG.Units;
+
+namespace PixiEditor.SVG.Elements;
+
+public class SvgImage : SvgElement
+{
+    public SvgProperty<SvgNumericUnit> X { get; } = new("x");
+    public SvgProperty<SvgNumericUnit> Y { get; } = new("y");
+    
+    public SvgProperty<SvgNumericUnit> Width { get; } = new("width");
+    public SvgProperty<SvgNumericUnit> Height { get; } = new("height");
+    
+    public SvgProperty<SvgStringUnit> Href { get; } = new("xlink:href");
+    
+    public SvgImage() : base("image")
+    {
+        RequiredNamespaces.Add("xlink", "http://www.w3.org/1999/xlink");
+    }
+}

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgLine.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgLine : SvgPrimitive
+public class SvgLine() : SvgPrimitive("line") 
 {
     public SvgProperty<SvgNumericUnit> X1 { get; } = new("x1");
     public SvgProperty<SvgNumericUnit> Y1 { get; } = new("y1");

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgPolyline.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgPolyline : SvgPrimitive
+public class SvgPolyline() : SvgPrimitive("polyline")
 {
     public SvgArray<SvgNumericUnit> Points { get; } = new SvgArray<SvgNumericUnit>("points");
 }

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgPrimitive.cs

@@ -3,7 +3,7 @@ using PixiEditor.SVG.Units;
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgPrimitive : SvgElement, ITransformable, IFillable, IStrokable
+public class SvgPrimitive(string tagName) : SvgElement(tagName), ITransformable, IFillable, IStrokable
 {
     public SvgProperty<SvgTransform> Transform { get; } = new("transform");
     public SvgProperty<SvgColorUnit> Fill { get; } = new("fill");

+ 1 - 1
src/PixiEditor.SVG/Elements/SvgRectangle.cs

@@ -2,7 +2,7 @@
 
 namespace PixiEditor.SVG.Elements;
 
-public class SvgRectangle : SvgPrimitive
+public class SvgRectangle() : SvgPrimitive("rect")
 {
     public SvgProperty<SvgNumericUnit> X { get; } = new("x");
     public SvgProperty<SvgNumericUnit> Y { get; } = new("y");

+ 6 - 0
src/PixiEditor.SVG/Features/IElementContainer.cs

@@ -0,0 +1,6 @@
+namespace PixiEditor.SVG.Features;
+
+public interface IElementContainer
+{
+    List<SvgElement> Children { get; }
+}

+ 52 - 5
src/PixiEditor.SVG/SvgDocument.cs

@@ -1,10 +1,57 @@
-using PixiEditor.Numerics;
+using System.Text;
+using PixiEditor.Numerics;
+using PixiEditor.SVG.Features;
 
 namespace PixiEditor.SVG;
 
-public class SvgDocument
+public class SvgDocument(RectD viewBox) : IElementContainer
 {
-   public string RootNamespace { get; set; } = "http://www.w3.org/2000/svg";
-   public string Version { get; set; } = "1.1";
-   public RectD ViewBox { get; set; }
+    public string RootNamespace { get; set; } = "http://www.w3.org/2000/svg";
+    public string Version { get; set; } = "1.1";
+    public RectD ViewBox { get; set; } = viewBox;
+    public List<SvgElement> Children { get; } = new();
+
+    public string ToXml()
+    {
+        StringBuilder builder = new();
+        builder.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
+        builder.AppendLine(
+            $"<svg xmlns=\"{RootNamespace}\" version=\"{Version}\" viewBox=\"{ViewBox.X} {ViewBox.Y} {ViewBox.Width} {ViewBox.Height}\"");
+
+        Dictionary<string, string> usedNamespaces = new();
+
+        GatherRequiredNamespaces(usedNamespaces, Children);
+
+        foreach (var usedNamespace in usedNamespaces)
+        {
+            builder.AppendLine(
+                $"xmlns:{usedNamespace.Key}=\"{usedNamespace.Value}\"");
+        }
+        
+        builder.AppendLine(">");
+
+        foreach (SvgElement child in Children)
+        {
+            builder.AppendLine(child.ToXml());
+        }
+
+        builder.AppendLine("</svg>");
+
+        return builder.ToString();
+    }
+
+    private void GatherRequiredNamespaces(Dictionary<string, string> usedNamespaces, List<SvgElement> elements)
+    {
+        foreach (SvgElement child in elements)
+        {
+            if (child is IElementContainer container)
+            {
+                GatherRequiredNamespaces(usedNamespaces, container.Children);
+            }
+            foreach (KeyValuePair<string, string> ns in child.RequiredNamespaces)
+            {
+                usedNamespaces[ns.Key] = ns.Value;
+            }
+        }
+    }
 }

+ 45 - 3
src/PixiEditor.SVG/SvgElement.cs

@@ -1,6 +1,48 @@
-namespace PixiEditor.SVG;
+using System.Text;
+using PixiEditor.SVG.Features;
 
-public class SvgElement
+namespace PixiEditor.SVG;
+
+public class SvgElement(string tagName)
 {
-    
+    public Dictionary<string, string> RequiredNamespaces { get; } = new();
+    public string TagName { get; } = tagName;
+
+    public string ToXml()
+    {
+        StringBuilder builder = new();
+        builder.Append($"<{TagName}");
+
+        foreach (var property in GetType().GetProperties())
+        {
+            if (property.PropertyType.IsAssignableTo(typeof(SvgProperty)))
+            {
+                SvgProperty prop = (SvgProperty)property.GetValue(this);
+                if (prop != null)
+                {
+                    if (prop.Unit != null)
+                    {
+                        builder.Append($" {prop.SvgName}=\"{prop.Unit.ToXml()}\"");
+                    }
+                }
+            }
+        }
+        
+        if (this is not IElementContainer container)
+        {
+            builder.Append(" />");
+        }
+        else
+        {
+            builder.Append(">");
+            foreach (SvgElement child in container.Children)
+            {
+                builder.AppendLine(child.ToXml());
+            }
+            
+            builder.Append($"</{TagName}>");
+        }
+
+        return builder.ToString();
+    }
 }

+ 4 - 4
src/PixiEditor.SVG/SvgProperty.cs

@@ -10,15 +10,15 @@ public abstract class SvgProperty
     }
 
     public string SvgName { get; set; }
-    public ISvgUnit Value { get; set; }
+    public ISvgUnit? Unit { get; set; }
 }
 
 public class SvgProperty<T> : SvgProperty where T : ISvgUnit
 {
-    public new T Value
+    public new T? Unit
     {
-        get => (T)base.Value;
-        set => base.Value = value;
+        get => (T?)base.Unit;
+        set => base.Unit = value;
     }
 
     public SvgProperty(string svgName) : base(svgName)

+ 5 - 0
src/PixiEditor.SVG/Units/SvgColorUnit.cs

@@ -33,4 +33,9 @@ public struct SvgColorUnit : ISvgUnit
     {
         return new SvgColorUnit($"hsla({h},{s}%,{l}%,{a})");
     }
+
+    public string ToXml()
+    {
+        return Value;
+    }
 }

+ 10 - 0
src/PixiEditor.SVG/Units/SvgNumericUnit.cs

@@ -5,6 +5,11 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
     public string PostFix { get; } = postFix;
     public double Value { get; set; } = value;
 
+    public static SvgNumericUnit FromUserUnits(double value)
+    {
+        return new SvgNumericUnit(value, string.Empty);
+    }
+    
     public static SvgNumericUnit FromPixels(double value)
     {
         return new SvgNumericUnit(value, "px");
@@ -29,4 +34,9 @@ public struct SvgNumericUnit(double value, string postFix) : ISvgUnit
     {
         return new SvgNumericUnit(value, "%");
     }
+
+    public string ToXml()
+    {
+        return $"{Value}{PostFix}";
+    }
 }

+ 4 - 0
src/PixiEditor.SVG/Units/SvgStringUnit.cs

@@ -8,4 +8,8 @@ public struct SvgStringUnit : ISvgUnit
     }
 
     public string Value { get; set; }
+    public string ToXml()
+    {
+        return Value;
+    }
 }

+ 4 - 0
src/PixiEditor.SVG/Units/SvgTransform.cs

@@ -9,4 +9,8 @@ public struct SvgTransform : ISvgUnit
     }
 
     public Matrix3x2 MatrixValue { get; set; } = Matrix3x2.Identity;
+    public string ToXml()
+    {
+        return $"matrix({MatrixValue.M11},{MatrixValue.M12},{MatrixValue.M21},{MatrixValue.M22},{MatrixValue.M31},{MatrixValue.M32})";
+    }
 }

+ 1 - 1
src/PixiEditor.SVG/Units/SvgUnit.cs

@@ -2,5 +2,5 @@
 
 public interface ISvgUnit
 {
-    
+    public string ToXml();
 }

+ 1 - 0
src/PixiEditor/Helpers/ServiceCollectionHelpers.cs

@@ -104,6 +104,7 @@ internal static class ServiceCollectionHelpers
             .AddSingleton<IoFileType, BmpFileType>()
             .AddSingleton<IoFileType, GifFileType>()
             .AddSingleton<IoFileType, Mp4FileType>()
+            .AddSingleton<IoFileType, SvgFileType>()
             // Serialization Factories
             .AddSingleton<SerializationFactory, SurfaceSerializationFactory>()
             .AddSingleton<SerializationFactory, ChunkyImageSerializationFactory>()

+ 35 - 0
src/PixiEditor/Models/Files/SvgFileType.cs

@@ -0,0 +1,35 @@
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.Models.Handlers;
+using PixiEditor.Models.IO;
+using PixiEditor.Numerics;
+using PixiEditor.SVG;
+using PixiEditor.SVG.Elements;
+using PixiEditor.SVG.Features;
+using PixiEditor.ViewModels.Document;
+using PixiEditor.ViewModels.Document.Nodes;
+
+namespace PixiEditor.Models.Files;
+
+internal class SvgFileType : IoFileType
+{
+    public override string[] Extensions { get; } = new[] { ".svg" };
+    public override string DisplayName { get; } = "Scalable Vector Graphics";
+    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
+
+    public override async Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
+    {
+        job?.Report(0, string.Empty);
+        SvgDocument svgDocument = document.ToSvgDocument(0, config.ExportSize);
+
+        job?.Report(0.5, string.Empty); 
+        string xml = svgDocument.ToXml();
+
+        job?.Report(0.75, string.Empty);
+        await using FileStream fileStream = new(pathWithExtension, FileMode.Create);
+        await using StreamWriter writer = new(fileStream);
+        await writer.WriteAsync(xml);
+        
+        job?.Report(1, string.Empty);
+        return SaveResult.Success;
+    }
+}

+ 0 - 14
src/PixiEditor/Models/Files/VectorFileType.cs

@@ -1,14 +0,0 @@
-using PixiEditor.Models.IO;
-using PixiEditor.ViewModels.Document;
-
-namespace PixiEditor.Models.Files;
-
-internal abstract class VectorFileType : IoFileType
-{
-    public override FileTypeDialogDataSet.SetKind SetKind { get; } = FileTypeDialogDataSet.SetKind.Vector;
-
-    public override Task<SaveResult> TrySave(string pathWithExtension, DocumentViewModel document, ExportConfig config, ExportJob? job)
-    {
-        throw new NotImplementedException(); 
-    }
-}

+ 24 - 9
src/PixiEditor/Models/IO/Exporter.cs

@@ -51,7 +51,8 @@ internal class Exporter
     /// <summary>
     /// Attempts to save file using a SaveFileDialog
     /// </summary>
-    public static async Task<ExporterResult> TrySaveWithDialog(DocumentViewModel document, ExportConfig exportConfig, ExportJob? job)
+    public static async Task<ExporterResult> TrySaveWithDialog(DocumentViewModel document, ExportConfig exportConfig,
+        ExportJob? job)
     {
         ExporterResult result = new(DialogSaveResult.UnknownError, null);
 
@@ -70,7 +71,8 @@ internal class Exporter
 
             var fileType = SupportedFilesHelper.GetSaveFileType(FileTypeDialogDataSet.SetKind.Any, file);
 
-            (SaveResult Result, string finalPath) saveResult = await TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, exportConfig, job);
+            (SaveResult Result, string finalPath) saveResult =
+                await TrySaveUsingDataFromDialog(document, file.Path.LocalPath, fileType, exportConfig, job);
             if (saveResult.Result == SaveResult.Success)
             {
                 result.Path = saveResult.finalPath;
@@ -85,7 +87,9 @@ internal class Exporter
     /// <summary>
     /// Takes data as returned by SaveFileDialog and attempts to use it to save the document
     /// </summary>
-    public static async Task<(SaveResult result, string finalPath)> TrySaveUsingDataFromDialog(DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, ExportConfig exportConfig, ExportJob? job)
+    public static async Task<(SaveResult result, string finalPath)> TrySaveUsingDataFromDialog(
+        DocumentViewModel document, string pathFromDialog, IoFileType fileTypeFromDialog, ExportConfig exportConfig,
+        ExportJob? job)
     {
         string finalPath = SupportedFilesHelper.FixFileExtension(pathFromDialog, fileTypeFromDialog);
         var saveResult = await TrySaveAsync(document, finalPath, exportConfig, job);
@@ -98,7 +102,8 @@ internal class Exporter
     /// <summary>
     /// Attempts to save the document into the given location, filetype is inferred from path
     /// </summary>
-    public static async Task<SaveResult> TrySaveAsync(DocumentViewModel document, string pathWithExtension, ExportConfig exportConfig, ExportJob? job)
+    public static async Task<SaveResult> TrySaveAsync(DocumentViewModel document, string pathWithExtension,
+        ExportConfig exportConfig, ExportJob? job)
     {
         string directory = Path.GetDirectoryName(pathWithExtension);
         if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
@@ -108,10 +113,19 @@ internal class Exporter
 
         if (typeFromPath is null)
             return SaveResult.UnknownError;
-        
-        var result = await typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
-        job?.Finish();
-        return result;
+
+        try
+        {
+            var result = await typeFromPath.TrySave(pathWithExtension, document, exportConfig, job);
+            job?.Finish();
+            return result;
+        }
+        catch (Exception e)
+        {
+            job?.Finish();
+            Console.WriteLine(e);
+            return SaveResult.UnknownError;
+        }
     }
 
     public static void SaveAsGZippedBytes(string path, Surface surface)
@@ -127,7 +141,8 @@ internal class Exporter
         var bytes = new byte[rectToSave.Width * rectToSave.Height * 8 + 8];
         try
         {
-            surface.DrawingSurface.ReadPixels(imageInfo, unmanagedBuffer, rectToSave.Width * 8, rectToSave.Left, rectToSave.Top);
+            surface.DrawingSurface.ReadPixels(imageInfo, unmanagedBuffer, rectToSave.Width * 8, rectToSave.Left,
+                rectToSave.Top);
             Marshal.Copy(unmanagedBuffer, bytes, 8, rectToSave.Width * rectToSave.Height * 8);
         }
         finally

+ 1 - 0
src/PixiEditor/PixiEditor.csproj

@@ -106,6 +106,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\PixiDocks\src\PixiDocks.Avalonia\PixiDocks.Avalonia.csproj"/>
+    <ProjectReference Include="..\PixiEditor.SVG\PixiEditor.SVG.csproj" />
     <ProjectReference Include="..\PixiParser\src\PixiParser.Skia\PixiParser.Skia.csproj"/>
     <ProjectReference Include="..\PixiParser\src\PixiParser\PixiParser.csproj"/>
     <ProjectReference Include="..\ChunkyImageLib\ChunkyImageLib.csproj"/>

+ 102 - 14
src/PixiEditor/ViewModels/Document/DocumentViewModel.Serialization.cs

@@ -3,6 +3,7 @@ using System.Drawing;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using Microsoft.Extensions.DependencyInjection;
+using PixiEditor.ChangeableDocument.Changeables.Animations;
 using PixiEditor.Helpers.Extensions;
 using PixiEditor.Models.IO.FileEncoders;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
@@ -13,6 +14,7 @@ using PixiEditor.DrawingApi.Core.Surfaces;
 using PixiEditor.DrawingApi.Core.Surfaces.ImageData;
 using PixiEditor.Extensions.CommonApi.Palettes;
 using PixiEditor.Helpers;
+using PixiEditor.Models.Handlers;
 using PixiEditor.Models.Serialization;
 using PixiEditor.Models.Serialization.Factories;
 using PixiEditor.Numerics;
@@ -21,7 +23,13 @@ using PixiEditor.Parser.Collections;
 using PixiEditor.Parser.Graph;
 using PixiEditor.Parser.Skia;
 using PixiEditor.Parser.Skia.Encoders;
+using PixiEditor.SVG;
+using PixiEditor.SVG.Elements;
+using PixiEditor.SVG.Features;
+using PixiEditor.SVG.Units;
+using PixiEditor.ViewModels.Document.Nodes;
 using IKeyFrameChildrenContainer = PixiEditor.ChangeableDocument.Changeables.Interfaces.IKeyFrameChildrenContainer;
+using KeyFrameData = PixiEditor.Parser.KeyFrameData;
 using PixiDocument = PixiEditor.Parser.Document;
 using ReferenceLayer = PixiEditor.Parser.ReferenceLayer;
 
@@ -35,12 +43,12 @@ internal partial class DocumentViewModel
         ImageEncoder encoder = new QoiEncoder();
         var serializationConfig = new SerializationConfig(encoder);
         var doc = Internals.Tracker.Document;
-        
+
         Dictionary<Guid, int> nodeIdMap = new();
         Dictionary<Guid, int> keyFrameIdMap = new();
 
         List<SerializationFactory> factories =
-            ViewModelMain.Current.Services.GetServices<SerializationFactory>().ToList(); // a bit ugly, sorry
+            ViewModelMain.Current.Services.GetServices<SerializationFactory>().ToList();
 
         AddNodes(doc.NodeGraph, graph, nodeIdMap, keyFrameIdMap, serializationConfig, factories);
 
@@ -61,7 +69,85 @@ internal partial class DocumentViewModel
         return document;
     }
 
-    private static void AddNodes(IReadOnlyNodeGraph graph, NodeGraph targetGraph, 
+    public SvgDocument ToSvgDocument(KeyFrameTime atTime, VecI exportSize)
+    {
+        SvgDocument svgDocument = new(new RectD(0, 0, exportSize.X, exportSize.Y));
+
+        float resizeFactorX = (float)exportSize.X / Width;
+        float resizeFactorY = (float)exportSize.Y / Height;
+        VecD resizeFactor = new VecD(resizeFactorX, resizeFactorY);
+
+        AddElements(NodeGraph.StructureTree.Members, svgDocument, atTime, resizeFactor);
+
+        return svgDocument;
+    }
+
+    private void AddElements(IEnumerable<INodeHandler> root, IElementContainer elementContainer, KeyFrameTime atTime,
+        VecD resizeFactor)
+    {
+        foreach (var member in root)
+        {
+            if (member is FolderNodeViewModel folderNodeViewModel)
+            {
+                var group = new SvgGroup();
+
+                AddElements(folderNodeViewModel.Children, group, atTime, resizeFactor);
+                elementContainer.Children.Add(group);
+            }
+
+            if (member is IRasterLayerHandler)
+            {
+                AddSvgImage(elementContainer, atTime, member, resizeFactor);
+            }
+        }
+    }
+
+    private void AddSvgImage(IElementContainer elementContainer, KeyFrameTime atTime, INodeHandler member,
+        VecD resizeFactor)
+    {
+        IReadOnlyImageNode imageNode = (IReadOnlyImageNode)Internals.Tracker.Document.FindNode(member.Id);
+
+        var tightBounds = imageNode.GetTightBounds(atTime);
+
+        if (tightBounds == null || tightBounds.Value.IsZeroArea) return;
+
+        SvgImage image = new SvgImage();
+
+        RectI bounds = (RectI)tightBounds.Value;
+
+        using Surface surface = new Surface(bounds.Size);
+        imageNode.GetLayerImageAtFrame(atTime.Frame).DrawMostUpToDateRegionOn(
+            (RectI)tightBounds.Value, ChunkResolution.Full, surface.DrawingSurface, VecI.Zero);
+
+        byte[] targetBytes;
+
+        RectD targetBounds = tightBounds.Value;
+
+        if (!resizeFactor.AlmostEquals(new VecD(1, 1)))
+        {
+            VecI newSize = new VecI((int)(bounds.Width * resizeFactor.X), (int)(bounds.Height * resizeFactor.Y));
+            using var resized = surface.Resize(newSize, ResizeMethod.NearestNeighbor);
+            using var snapshot = resized.DrawingSurface.Snapshot();
+            targetBytes = snapshot.Encode().AsSpan().ToArray();
+            
+            targetBounds = new RectD(targetBounds.X * resizeFactor.X, targetBounds.Y * resizeFactor.Y, newSize.X, newSize.Y);
+        }
+        else
+        {
+            using var snapshot = surface.DrawingSurface.Snapshot();
+            targetBytes = snapshot.Encode().AsSpan().ToArray();
+        }
+
+        image.X.Unit = SvgNumericUnit.FromUserUnits(targetBounds.X);
+        image.Y.Unit = SvgNumericUnit.FromUserUnits(targetBounds.Y);
+        image.Width.Unit = SvgNumericUnit.FromUserUnits(targetBounds.Width);
+        image.Height.Unit = SvgNumericUnit.FromUserUnits(targetBounds.Height);
+        image.Href.Unit = new SvgStringUnit($"data:image/png;base64,{Convert.ToBase64String(targetBytes)}");
+
+        elementContainer.Children.Add(image);
+    }
+
+    private static void AddNodes(IReadOnlyNodeGraph graph, NodeGraph targetGraph,
         Dictionary<Guid, int> nodeIdMap,
         Dictionary<Guid, int> keyFrameIdMap,
         SerializationConfig config, IReadOnlyList<SerializationFactory> allFactories)
@@ -85,7 +171,8 @@ internal partial class DocumentViewModel
                 properties[i] = new NodePropertyValue()
                 {
                     PropertyName = node.InputProperties[i].InternalPropertyName,
-                    Value = SerializationUtil.SerializeObject(node.InputProperties[i].NonOverridenValue, config, allFactories)
+                    Value = SerializationUtil.SerializeObject(node.InputProperties[i].NonOverridenValue, config,
+                        allFactories)
                 };
             }
 
@@ -93,23 +180,23 @@ internal partial class DocumentViewModel
             node.SerializeAdditionalData(additionalData);
 
             KeyFrameData[] keyFrames = new KeyFrameData[node.KeyFrames.Count];
-            
+
             for (int i = 0; i < node.KeyFrames.Count; i++)
             {
                 keyFrameIdMap[node.KeyFrames[i].KeyFrameGuid] = keyFrameId + 1;
                 keyFrames[i] = new KeyFrameData
                 {
                     Id = keyFrameId + 1,
-                    Data = SerializationUtil.SerializeObject(node.KeyFrames[i].Data, config, allFactories), 
+                    Data = SerializationUtil.SerializeObject(node.KeyFrames[i].Data, config, allFactories),
                     AffectedElement = node.KeyFrames[i].AffectedElement,
-                    StartFrame = node.KeyFrames[i].StartFrame, 
+                    StartFrame = node.KeyFrames[i].StartFrame,
                     Duration = node.KeyFrames[i].Duration,
                     IsVisible = node.KeyFrames[i].IsVisible
                 };
-                
+
                 keyFrameId++;
             }
-                
+
             Dictionary<string, object> converted = ConvertToSerializable(additionalData, config, allFactories);
 
             List<PropertyConnection> connections = new();
@@ -181,7 +268,7 @@ internal partial class DocumentViewModel
 
         var shape = layer.Shape;
         var imageSize = layer.ImageSize;
-        
+
         var imageBytes = config.Encoder.Encode(layer.ImageBgra8888Bytes.ToArray(), imageSize.X, imageSize.Y);
 
         return new ReferenceLayer
@@ -204,7 +291,8 @@ internal partial class DocumentViewModel
     private ColorCollection ToCollection(IList<PaletteColor> collection) =>
         new(collection.Select(x => Color.FromArgb(255, x.R, x.G, x.B)));
 
-    private AnimationData ToAnimationData(IReadOnlyAnimationData animationData, IReadOnlyNodeGraph graph, Dictionary<Guid, int> nodeIdMap, Dictionary<Guid, int> keyFrameIds)
+    private AnimationData ToAnimationData(IReadOnlyAnimationData animationData, IReadOnlyNodeGraph graph,
+        Dictionary<Guid, int> nodeIdMap, Dictionary<Guid, int> keyFrameIds)
     {
         var animData = new AnimationData();
         animData.KeyFrameGroups = new List<KeyFrameGroup>();
@@ -241,7 +329,8 @@ internal partial class DocumentViewModel
         }
     }
 
-    private static void BuildRasterKeyFrame(IReadOnlyRasterKeyFrame rasterKeyFrame, IReadOnlyNodeGraph graph, KeyFrameGroup group,
+    private static void BuildRasterKeyFrame(IReadOnlyRasterKeyFrame rasterKeyFrame, IReadOnlyNodeGraph graph,
+        KeyFrameGroup group,
         Dictionary<Guid, int> idMap, Dictionary<Guid, int> keyFrameIds)
     {
         IReadOnlyChunkyImage image = rasterKeyFrame.GetTargetImage(graph.AllNodes);
@@ -261,8 +350,7 @@ internal partial class DocumentViewModel
 
         group.Children.Add(new ElementKeyFrame()
         {
-            NodeId = idMap[rasterKeyFrame.NodeId],
-            KeyFrameId = keyFrameIds[rasterKeyFrame.Id],
+            NodeId = idMap[rasterKeyFrame.NodeId], KeyFrameId = keyFrameIds[rasterKeyFrame.Id],
         });
     }
 }

+ 4 - 1
src/PixiEditor/ViewModels/SubViewModels/FileViewModel.cs

@@ -435,7 +435,10 @@ internal class FileViewModel : SubViewModel<ViewModelMain>
                     }
                     else
                     {
-                        ShowSaveError((DialogSaveResult)result.result);
+                        Dispatcher.UIThread.Post(() =>
+                        {
+                            ShowSaveError((DialogSaveResult)result.result);
+                        });
                     }
                 });
 

+ 2 - 2
src/PixiEditor/Views/Dialogs/ExportFilePopup.axaml.cs

@@ -415,7 +415,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
             FileTypeChoices =
                 SupportedFilesHelper.BuildSaveFilter(SelectedExportIndex == 1
                     ? FileTypeDialogDataSet.SetKind.Video
-                    : FileTypeDialogDataSet.SetKind.Image),
+                    : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector),
             ShowOverwritePrompt = true
         };
 
@@ -427,7 +427,7 @@ internal partial class ExportFilePopup : PixiEditorPopup
                 SaveFormat = SupportedFilesHelper.GetSaveFileType(
                     SelectedExportIndex == 1
                         ? FileTypeDialogDataSet.SetKind.Video
-                        : FileTypeDialogDataSet.SetKind.Image, file);
+                        : FileTypeDialogDataSet.SetKind.Image | FileTypeDialogDataSet.SetKind.Vector, file);
                 if (SaveFormat == null)
                 {
                     return null;

+ 11 - 10
src/PixiEditor/Views/Dialogs/ProgressDialog.cs

@@ -8,16 +8,12 @@ namespace PixiEditor.Views.Dialogs;
 internal class ProgressDialog : CustomDialog
 {
     public ExportJob Job { get; }
-    
+
+    private ProgressPopup popup;
+
     public ProgressDialog(ExportJob job, Window ownerWindow) : base(ownerWindow)
     {
         Job = job;
-    }
-
-    public override async Task<bool> ShowDialog()
-    {
-        ProgressPopup popup = new ProgressPopup();
-        popup.CancellationToken = Job.CancellationTokenSource;
         Job.ProgressChanged += (progress, status) =>
         {
             Dispatcher.UIThread.Post(() =>
@@ -26,7 +22,7 @@ internal class ProgressDialog : CustomDialog
                 popup.Status = status;
             });
         };
-        
+
         Job.Finished += () =>
         {
             Dispatcher.UIThread.Post(() =>
@@ -34,7 +30,7 @@ internal class ProgressDialog : CustomDialog
                 popup.Close();
             });
         };
-        
+
         Job.Cancelled += () =>
         {
             Dispatcher.UIThread.Post(() =>
@@ -42,7 +38,12 @@ internal class ProgressDialog : CustomDialog
                 popup.Close();
             });
         };
-        
+    }
+
+    public override async Task<bool> ShowDialog()
+    {
+        popup = new ProgressPopup();
+        popup.CancellationToken = Job.CancellationTokenSource;
         return await popup.ShowDialog<bool>(OwnerWindow);
     }
 }