Files
ebbes-meme-clipboard/src/EbbesMemeClipboard/Services/ClipboardService.cs
T

190 lines
7.9 KiB
C#

using System.Collections.Specialized;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Media.Imaging;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed partial class ClipboardService : IClipboardService
{
public async Task CopyLocalFileAsync(string filePath)
{
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
var data = new DataObject();
// CF_HDROP: lets Discord/Slack/Teams/Explorer paste the real file (animated, for GIFs).
data.SetFileDropList(new StringCollection { filePath });
// CF_DIB fallback: apps that only accept bitmap paste (e.g. Paint) get a static image.
// WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
data.SetImage(firstFrame);
// "PNG": a registered clipboard format (not one of the classic CF_* constants) that
// Chromium-based apps specifically look for and prefer over CF_DIB/CF_BITMAP when
// reading a pasted image, because those older formats are lossy for transparency. This
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
//
// Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
// Chromium PREFERS this format over all others, offering it for a GIF is what makes
// animated GIFs paste as a still first frame. Omitting it lets those targets fall
// through to CF_HTML below, which carries the full animated data URI.
if (!ImageDecoding.IsGif(filePath))
{
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
}
// CF_HTML: the other route those same web-based paste targets check, and the only one of
// these formats that preserves animation for them, for GIFs.
var bytes = await File.ReadAllBytesAsync(filePath);
var fragment = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
await SetClipboardWithRetryAsync(data);
}
private static string GetMimeType(string filePath) =>
Path.GetExtension(filePath).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
_ => "application/octet-stream",
};
private static readonly string[] ImportableExtensions = { ".jpg", ".jpeg", ".png", ".gif" };
/// <summary>
/// Formats are checked in descending order of fidelity, which matters a lot here: a GIF is
/// only still animated in the first two. Falling straight to the bitmap (as a naive
/// implementation would) silently flattens every pasted GIF to one frame.
/// </summary>
public ClipboardImportPayload? TryReadImage()
{
// 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact.
if (Clipboard.ContainsFileDropList())
{
var paths = Clipboard.GetFileDropList()
.Cast<string>()
.Where(p => !string.IsNullOrWhiteSpace(p) &&
ImportableExtensions.Contains(Path.GetExtension(p).ToLowerInvariant()))
.ToList();
if (paths.Count > 0)
{
return new ClipboardImportPayload { FilePaths = paths };
}
}
// 2. CF_HTML with an embedded data: URI - how a real (possibly animated) GIF arrives
// when copied out of a browser or out of this app itself.
if (Clipboard.ContainsText(TextDataFormat.Html))
{
var match = DataUriRegex().Match(Clipboard.GetText(TextDataFormat.Html));
if (match.Success)
{
try
{
var bytes = Convert.FromBase64String(match.Groups["data"].Value);
var ext = match.Groups["mime"].Value.ToLowerInvariant() switch
{
"gif" => ".gif",
"jpeg" or "jpg" => ".jpg",
_ => ".png",
};
return new ClipboardImportPayload { Data = bytes, Extension = ext };
}
catch (FormatException)
{
// Malformed base64 - fall through to the bitmap formats below.
}
}
}
// 3. The registered "PNG" format - lossless and keeps transparency, unlike CF_DIB.
if (Clipboard.ContainsData("PNG") && Clipboard.GetData("PNG") is { } pngData)
{
byte[]? bytes = pngData switch
{
MemoryStream ms => ms.ToArray(),
byte[] raw => raw,
_ => null,
};
if (bytes is { Length: > 0 })
{
return new ClipboardImportPayload { Data = bytes, Extension = ".png" };
}
}
// 4. Plain bitmap (Snipping Tool, Paint) - always static, so it's the last resort.
if (Clipboard.ContainsImage() && Clipboard.GetImage() is { } bitmap)
{
using var stream = new MemoryStream();
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
encoder.Save(stream);
return new ClipboardImportPayload { Data = stream.ToArray(), Extension = ".png" };
}
return null;
}
[GeneratedRegex(@"data:image/(?<mime>gif|png|jpe?g);base64,(?<data>[A-Za-z0-9+/=]+)", RegexOptions.IgnoreCase)]
private static partial Regex DataUriRegex();
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
Clipboard.SetDataObject(data, copy: true);
return;
}
catch (COMException) when (attempt < maxAttempts)
{
// Another process is transiently holding the clipboard - back off and retry.
await Task.Delay(75);
}
}
}
/// <summary>
/// Builds a CF_HTML clipboard payload. WPF's DataObject.SetData(DataFormats.Html, ...) does
/// NOT wrap plain HTML into CF_HTML automatically - the Version/StartHTML/EndHTML/
/// StartFragment/EndFragment header with exact byte offsets has to be built by hand, or
/// paste targets will reject or mis-render the fragment.
/// </summary>
private static string BuildCfHtml(string htmlFragment)
{
const string prefix = "<html><body><!--StartFragment-->";
const string suffix = "<!--EndFragment--></body></html>";
int headerLength = Encoding.UTF8.GetByteCount(FormatHeader(0, 0, 0, 0));
int startHtml = headerLength;
int startFragment = startHtml + Encoding.UTF8.GetByteCount(prefix);
int endFragment = startFragment + Encoding.UTF8.GetByteCount(htmlFragment);
int endHtml = endFragment + Encoding.UTF8.GetByteCount(suffix);
return FormatHeader(startHtml, endHtml, startFragment, endFragment) + prefix + htmlFragment + suffix;
}
private static string FormatHeader(int startHtml, int endHtml, int startFragment, int endFragment) =>
"Version:0.9\r\n" +
$"StartHTML:{startHtml:D10}\r\n" +
$"EndHTML:{endHtml:D10}\r\n" +
$"StartFragment:{startFragment:D10}\r\n" +
$"EndFragment:{endFragment:D10}\r\n";
}