190 lines
7.9 KiB
C#
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";
|
|
}
|