Key Methods: ToDto(), LoadFromDto() — DTO Types: DiagramDto, ComponentDto, LineDto

Serialization Flow

The serialization system transforms the live diagram state into a portable JSON representation and back again. The process uses plain Data Transfer Objects (DTOs) serialized via System.Text.Json, ensuring no dependency on Newtonsoft.Json or binary formatters.

DrawingManager
→
ToDto()
→
DiagramDto
→
JSON String
→
File / Stream
DrawingManager
←
LoadFromDto()
←
DiagramDto
←
JSON String
←
File / Stream

The DiagramDto is the root container:

DiagramDto Structure

namespace Beep.Skia.Serialization
{
    public class DiagramDto
    {
        public const int CurrentSchemaVersion = 2;

        public int SchemaVersion { get; set; } = CurrentSchemaVersion;
        public string ThemeName { get; set; }

        public List<ComponentDto> Components { get; set; } = new();
        public List<LineDto> Lines { get; set; } = new();
    }
}

Component Serialization

Each SkiaComponent on the canvas is serialized into a ComponentDto. The DTO captures the minimum information needed to reconstruct the component: its position, size, type identity, configurable properties, and connection-point GUIDs:

ComponentDto Structure

public class ComponentDto
{
    public string Type { get; set; }              // assembly-qualified type name
    public float X { get; set; }
    public float Y { get; set; }
    public float Width { get; set; }
    public float Height { get; set; }
    public string Name { get; set; }

    // Lightweight string values
    public Dictionary<string, string> PropertyBag { get; set; } = new();

    // Typed values (lists, dictionaries, nested objects) as raw JSON elements
    public Dictionary<string, JsonElement> TypedPropertyBag { get; set; } = new();

    // Persisted connection point IDs for deterministic identity across sessions
    public List<Guid> InPointIds { get; set; } = new();
    public List<Guid> OutPointIds { get; set; } = new();
}

Property serialization splits values into two bags: simple values that survive a string round-trip go into PropertyBag, while complex values are stored as JsonElement entries in TypedPropertyBag so they survive deserialization intact.

ToDto() — Serializing Components

var dto = new DiagramDto
{
    ThemeName = theme.Name
};

foreach (var component in drawingManager.GetComponents())
{
    var cDto = new ComponentDto
    {
        Type = component.GetType().AssemblyQualifiedName,
        X = component.X,
        Y = component.Y,
        Width = component.Width,
        Height = component.Height,
        Name = component.Name
    };

    foreach (var kvp in component.NodeProperties)
    {
        // Store simple values in the string bag; complex values in the typed bag
        if (kvp.Value.ParameterCurrentValue is string s)
            cDto.PropertyBag[kvp.Key] = s;
        else
            cDto.TypedPropertyBag[kvp.Key] =
                JsonSerializer.SerializeToElement(kvp.Value.ParameterCurrentValue);
    }

    cDto.InPointIds.AddRange(component.InConnectionPoints.Select(p => p.Id));
    cDto.OutPointIds.AddRange(component.OutConnectionPoints.Select(p => p.Id));

    dto.Components.Add(cDto);
}

var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("diagram.json", json);

Connection Serialization

Connection lines are serialized by reference — they store the GUIDs of their start and end ConnectionPoint IDs rather than embedding the full port data. During deserialization, a GUID-to-port lookup index resolves these references:

LineDto Structure

public class LineDto
{
    public Guid StartPointId { get; set; }
    public Guid EndPointId { get; set; }

    // Used by templates, before GUIDs exist
    public int StartComponentIndex { get; set; } = -1;
    public int EndComponentIndex { get; set; } = -1;

    public bool ShowStartArrow { get; set; } = true;
    public bool ShowEndArrow { get; set; } = true;
    public string Label1 { get; set; }
    public string Label2 { get; set; }
    public string Label3 { get; set; }
    public string DataTypeLabel { get; set; }

    // State-machine transition semantics
    public string GuardCondition { get; set; }
    public string TransitionAction { get; set; }
    public string TriggerEvent { get; set; }

    public uint LineColor { get; set; }     // SKColor as RGBA uint
    public int RoutingMode { get; set; }    // LineRoutingMode
    public int FlowDirection { get; set; }  // DataFlowDirection

    public float ArrowSize { get; set; }
    public float[] DashPattern { get; set; }

    public int StartMultiplicity { get; set; }  // ERDMultiplicity
    public int EndMultiplicity { get; set; }

    public string SchemaJson { get; set; }
    public string ExpectedSchemaJson { get; set; }
}

Enum values are stored as integers and colors as RGBA uint values, keeping the JSON compact and round-trip safe.

Deserialization

Loading a diagram from JSON involves several coordinated steps to reconstruct the full object graph:

  1. Parse JSON: The JSON string is deserialized into a DiagramDto using System.Text.Json.
  2. Clear and theme: LoadFromDto clears the current components and applies the persisted ThemeName.
  3. Look Up Types: Each ComponentDto.Type is an assembly-qualified name resolved with Type.GetType(); unknown types are skipped.
  4. Instantiate Components: Components are created with Activator.CreateInstance(), positioned, and named from the DTO.
  5. Restore Property Values: PropertyBag and TypedPropertyBag entries are applied through SetPropperties() (which also updates the component's NodeProperties metadata).
  6. Restore Port Identity: Persisted InPointIds/OutPointIds are re-applied to the component's connection points so line references stay stable.
  7. Restore Connections: For each LineDto, ports are resolved by GUID (or by component index for templates) and reconnected.

Saving and Loading a Diagram

// Save
var dto = drawingManager.ToDto();
var json = JsonSerializer.Serialize(dto, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText("diagram.json", json);

// Load
var restored = JsonSerializer.Deserialize<DiagramDto>(File.ReadAllText("diagram.json"));
drawingManager.LoadFromDto(restored);
drawingManager.RequestRedraw();

Versioning

The SchemaVersion property in DiagramDto is an int that identifies the payload shape. DiagramDto.CurrentSchemaVersion is currently 2 and is written on every save:

VersionChangesCompatibility
1Initial format. Components with position, size and type, plus a flat property bag.Loads; complex property values fall back to string conversion.
2Added TypedPropertyBag for complex values and persisted connection-point IDs (InPointIds/OutPointIds).Current. Full feature parity.

Loading is tolerant by design: missing bags and port-ID lists are treated as empty, and any property that cannot be applied is skipped rather than failing the load. Check SchemaVersion yourself if you need to run custom upgrade logic for payloads you produced outside the framework:

Version Check on Load

var dto = JsonSerializer.Deserialize<DiagramDto>(json);

if (dto.SchemaVersion < DiagramDto.CurrentSchemaVersion)
{
    // Optional: migrate legacy payloads before loading
    UpgradeLegacyDto(dto);
}

drawingManager.LoadFromDto(dto);

Note

Serialized files always carry DiagramDto.CurrentSchemaVersion. LoadFromDto reads both bags and tolerates older payloads, so the migration hook above is only needed for custom transformations.

Complete Save/Load Example

Below is a complete end-to-end example demonstrating saving a diagram to a file and loading it back:

Saving a Diagram

using System.Text.Json;

var drawingManager = new DrawingManager();

var start = new ProcessNode { Name = "Start" };
start.X = 100; start.Y = 100;
drawingManager.AddComponent(start);

var process = new ProcessNode { Name = "Process Data" };
process.X = 400; process.Y = 100;
drawingManager.AddComponent(process);

var decision = new DecisionNode { Label = "Valid?" };
decision.X = 250; decision.Y = 300;
drawingManager.AddComponent(decision);

drawingManager.ConnectComponents(start, process);
drawingManager.ConnectComponents(process, decision);

var dto = drawingManager.ToDto();

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

string json = JsonSerializer.Serialize(dto, options);
File.WriteAllText("workflow_diagram.json", json);

Console.WriteLine($"Saved diagram with {dto.Components.Count} components " +
                  $"and {dto.Lines.Count} lines.");

Loading a Diagram

string json = File.ReadAllText("workflow_diagram.json");

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

var dto = JsonSerializer.Deserialize<DiagramDto>(json, options);

var drawingManager = new DrawingManager();

drawingManager.LoadFromDto(dto);

drawingManager.RequestRedraw();

Console.WriteLine($"Loaded: {drawingManager.GetComponents().Count} components, " +
                  $"{drawingManager.GetLines().Count} lines");