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 = $""; 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" }; /// /// 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. /// public ClipboardImportPayload? TryReadImage() { // 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact. if (Clipboard.ContainsFileDropList()) { var paths = Clipboard.GetFileDropList() .Cast() .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/(?gif|png|jpe?g);base64,(?[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); } } } /// /// 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. /// private static string BuildCfHtml(string htmlFragment) { const string prefix = ""; const string suffix = ""; 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"; }