Editor/Prism/Serialization/ClipboardCodec.cs

ClipboardCodec and PasteResult for the Prism editor. PasteResult is a small record describing the result of a paste (success, nodes, center, error). ClipboardCodec encodes and decodes clipboard payloads with a fixed prefix, Base64 and GZip, bounds decompressed size, and provides Paste and Duplicate operations that serialize/deserialize node fragments and position them in the graph.

File Access
using Editor.Prism.Core;
using Editor.Prism.Model;
using System.IO;
using System.IO.Compression;
using System.Text;

namespace Editor.Prism.Serialization;

/// <summary>What a paste produced.</summary>
public sealed record PasteResult
{
	/// <summary>True when the payload was Prism's and at least part of it was pasted.</summary>
	public bool Ok { get; init; }

	/// <summary>The nodes that were added, with freshly minted ids.</summary>
	public IReadOnlyList<PrismNode> Nodes { get; init; } = Array.Empty<PrismNode>();

	/// <summary>Why the paste did nothing, when it did nothing.</summary>
	public string Error { get; init; }

	/// <summary>The centre of the pasted selection in scene space, for framing the view.</summary>
	public Vector2 Center { get; init; }

	/// <summary>The failure result.</summary>
	public static PasteResult Failed( string error ) => new() { Ok = false, Error = error };

	/// <inheritdoc/>
	public override string ToString() => Ok ? $"pasted {Nodes.Count} nodes" : $"paste failed: {Error}";
}

/// <summary>
/// The clipboard format: <c>"prism.graph:"</c> followed by Base64 of a GZipped document fragment.
/// <para>
/// The identifier prefix is not decoration. The node-graph framework's paste path checks only that
/// the clipboard text starts with the editor's own prefix, so an editor that reuses another's prefix
/// happily hands a foreign graph to its own deserializer and throws. Prism's prefix is unique, and
/// <see cref="TryDecode"/> refuses anything that is not ours before a single byte is parsed.
/// </para>
/// </summary>
public static class ClipboardCodec
{
	/// <summary>The literal prefix every Prism clipboard payload starts with.</summary>
	public const string Prefix = PrismConstants.ClipboardIdent + ":";

	/// <summary>
	/// The largest fragment a paste will decompress, in bytes.
	/// <para>
	/// The clipboard is the one input to the editor that comes from outside it entirely, and a few
	/// kilobytes of GZip expands to as many gigabytes as the author of it likes. A five-thousand-node
	/// document is under two megabytes, so this is generous by any real measure and still bounded.
	/// </para>
	/// </summary>
	public const int MaxDecodedBytes = 64 * 1024 * 1024;

	/// <summary>True when the text looks like a Prism payload. Cheap enough to call from a menu-enable check.</summary>
	public static bool IsPrismPayload( string text ) =>
		!string.IsNullOrEmpty( text ) && text.StartsWith( Prefix, StringComparison.Ordinal );

	/// <summary>Encode a selection as a clipboard payload.</summary>
	public static string Encode( PrismGraph graph, IEnumerable<PrismNode> nodes ) =>
		EncodeJson( PrismSerializer.WriteNodes( graph, nodes ) );

	/// <summary>Wrap an already-serialized fragment as a clipboard payload.</summary>
	public static string EncodeJson( string json )
	{
		if ( string.IsNullOrEmpty( json ) ) return Prefix;

		return PrismLog.Guard( "Encode clipboard payload", () =>
		{
			using var buffer = new MemoryStream();

			using ( var zip = new GZipStream( buffer, CompressionMode.Compress, true ) )
			{
				var bytes = Encoding.UTF8.GetBytes( json );
				zip.Write( bytes, 0, bytes.Length );
			}

			return Prefix + System.Convert.ToBase64String( buffer.ToArray() );
		}, Prefix );
	}

	/// <summary>
	/// Decode a clipboard payload. Returns false for anything that is not ours, for anything that is
	/// not valid Base64, and for anything that is not valid GZip — never throws on hostile input.
	/// </summary>
	public static bool TryDecode( string payload, out string json )
	{
		json = null;

		if ( !IsPrismPayload( payload ) ) return false;

		var encoded = payload[Prefix.Length..].Trim();

		if ( encoded.Length == 0 ) return false;

		byte[] compressed;

		try
		{
			compressed = System.Convert.FromBase64String( encoded );
		}
		catch ( FormatException )
		{
			return false;
		}

		try
		{
			using var input = new MemoryStream( compressed );
			using var zip = new GZipStream( input, CompressionMode.Decompress );
			using var output = new MemoryStream();

			var chunk = new byte[64 * 1024];

			while ( true )
			{
				var read = zip.Read( chunk, 0, chunk.Length );

				if ( read <= 0 ) break;

				if ( output.Length + read > MaxDecodedBytes )
				{
					PrismLog.Warn( $"A clipboard payload expanded past {MaxDecodedBytes / ( 1024 * 1024 )} MB " +
						"and was refused." );

					return false;
				}

				output.Write( chunk, 0, read );
			}

			json = Encoding.UTF8.GetString( output.ToArray() );
		}
		catch ( Exception e )
		{
			PrismLog.Trace( $"Clipboard payload could not be decompressed: {e.Message}" );
			return false;
		}

		return !string.IsNullOrWhiteSpace( json );
	}

	/// <summary>
	/// Paste a payload into a document, re-minting every node id and rewiring the fragment's internal
	/// edges to the new ids, then translating the result so its centre lands on
	/// <paramref name="origin"/>.
	/// <para>
	/// Ids are re-minted rather than preserved because a paste always creates <em>new</em> nodes, even
	/// in the document they were copied from. Undo restore is the opposite case and uses
	/// <see cref="PrismSerializer.Restore"/>, which keeps ids exactly.
	/// </para>
	/// </summary>
	public static PasteResult Paste( PrismGraph graph, string payload, Vector2 origin,
		DiagnosticSink sink = null )
	{
		if ( graph is null ) return PasteResult.Failed( "No document" );

		if ( !TryDecode( payload, out var json ) )
		{
			return PasteResult.Failed( IsPrismPayload( payload )
				? "The clipboard payload is damaged"
				: "The clipboard does not contain Prism nodes" );
		}

		var added = PrismSerializer.ReadNodes( graph, json, true, sink );

		if ( added.Count == 0 ) return PasteResult.Failed( "The clipboard payload contained no nodes" );

		var center = Recenter( added, origin );

		return new PasteResult { Ok = true, Nodes = added, Center = center };
	}

	/// <summary>
	/// Duplicate nodes inside their own document: the same path as paste, but without a round trip
	/// through the system clipboard, and offset rather than re-centred.
	/// </summary>
	public static PasteResult Duplicate( PrismGraph graph, IEnumerable<PrismNode> nodes, Vector2 offset,
		DiagnosticSink sink = null )
	{
		if ( graph is null ) return PasteResult.Failed( "No document" );

		var selection = nodes?.Where( x => x is not null ).ToArray() ?? Array.Empty<PrismNode>();

		if ( selection.Length == 0 ) return PasteResult.Failed( "Nothing to duplicate" );

		var json = PrismSerializer.WriteNodes( graph, selection );
		var added = PrismSerializer.ReadNodes( graph, json, true, sink );

		if ( added.Count == 0 ) return PasteResult.Failed( "The selection could not be duplicated" );

		foreach ( var node in added )
		{
			node.Position += offset;
		}

		return new PasteResult { Ok = true, Nodes = added, Center = Average( added ) };
	}

	static Vector2 Recenter( IReadOnlyList<PrismNode> nodes, Vector2 origin )
	{
		var average = Average( nodes );
		var delta = origin - average;

		foreach ( var node in nodes )
		{
			node.Position += delta;
		}

		return origin;
	}

	static Vector2 Average( IReadOnlyList<PrismNode> nodes )
	{
		if ( nodes is null || nodes.Count == 0 ) return Vector2.Zero;

		var x = 0f;
		var y = 0f;

		foreach ( var node in nodes )
		{
			x += node.Position.x;
			y += node.Position.y;
		}

		return new Vector2( x / nodes.Count, y / nodes.Count );
	}
}