using System.Collections.Concurrent; using System.IO; using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using System.Xml; using SharpVectors.Converters; using SharpVectors.Dom; using SharpVectors.Renderers.Wpf; namespace XFEToolBox.Client.Utilities; /// /// 将 HTTP(S) 或 data:image 地址统一解码为可跨线程使用的 WPF 图像。 /// 支持 WIC 位图格式(包括 ICO、静态或动态 GIF)以及 SVG/SVGZ。 /// GIF 会保留原始解码器和全部帧,供 WpfAnimatedGif 在界面中播放。 /// public static class WebImageSourceLoader { public const int MaximumImageBytes = 4 * 1024 * 1024; private const int MaximumCachedImages = 256; private static readonly SemaphoreSlim DecodeGate = new(Math.Clamp(Environment.ProcessorCount / 2, 1, 4)); private static readonly ConcurrentDictionary>> Cache = new(StringComparer.Ordinal); private static readonly HttpClient ImageClient = CreateImageClient(); /// /// 异步读取网络地址或 data:image URI。失败会抛出可诊断异常,不缓存失败结果。 /// public static async Task LoadAsync(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; value = value.Trim(); var displayDispatcher = GetDisplayDispatcher(); // data URI 可能包含数 MB 文本,计算缓存键也不能占用 UI 线程。 var cacheKey = value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase) ? await Task.Run(() => CreateCacheKey(value)).ConfigureAwait(false) : value; if (Cache.Count >= MaximumCachedImages && !Cache.ContainsKey(cacheKey)) Cache.Clear(); var lazy = Cache.GetOrAdd( cacheKey, _ => new Lazy>( () => LoadCoreAsync(value, displayDispatcher), LazyThreadSafetyMode.ExecutionAndPublication)); try { var result = await lazy.Value.ConfigureAwait(false); if (result is null) Cache.TryRemove(new KeyValuePair>>(cacheKey, lazy)); return result; } catch { Cache.TryRemove(new KeyValuePair>>(cacheKey, lazy)); throw; } } /// /// 从已下载的内容解码图像;公开此入口以供缓存层和测试复用。 /// public static ImageSource Decode(byte[] bytes, string? mediaType = null, string? sourceName = null) { ArgumentNullException.ThrowIfNull(bytes); if (bytes.Length == 0) throw new InvalidDataException("图标内容为空。"); if (bytes.Length > MaximumImageBytes) throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); bytes = DecompressSvgzIfNeeded(bytes, mediaType, sourceName); return IsSvg(bytes, mediaType, sourceName) ? DecodeSvg(bytes) : DecodeBitmap(bytes); } /// /// 在合适的线程中完成图像解码:SVG 与普通位图在受限后台线程执行, /// 动态 GIF 则在显示 Dispatcher 上创建,以保留可用的多帧解码器。 /// public static Task DecodeAsync(byte[] bytes, string? mediaType = null, string? sourceName = null) => DecodeForDisplayAsync(bytes, mediaType, sourceName, GetDisplayDispatcher()); private static async Task LoadCoreAsync(string value, Dispatcher? displayDispatcher) { if (value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)) { var decodedData = await Task.Run(() => DecodeDataUri(value)).ConfigureAwait(false); return await DecodeForDisplayAsync( decodedData.Bytes, decodedData.MediaType, decodedData.MediaType, displayDispatcher) .ConfigureAwait(false); } if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) throw new ArgumentException("图标地址必须是 HTTP(S) 地址或 data:image URI。", nameof(value)); using var request = new HttpRequestMessage(HttpMethod.Get, uri); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("image/svg+xml")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("image/*", 0.9)); using var response = await ImageClient .SendAsync(request, HttpCompletionOption.ResponseHeadersRead) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); var contentLength = response.Content.Headers.ContentLength; if (contentLength > MaximumImageBytes) throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); var bytes = await ReadLimitedAsync(responseStream).ConfigureAwait(false); return await DecodeForDisplayAsync( bytes, response.Content.Headers.ContentType?.MediaType, uri.AbsolutePath, displayDispatcher) .ConfigureAwait(false); } private static async Task ReadLimitedAsync(Stream stream) { using var output = new MemoryStream(); var buffer = new byte[32 * 1024]; while (true) { var count = await stream.ReadAsync(buffer).ConfigureAwait(false); if (count == 0) break; if (output.Length + count > MaximumImageBytes) throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); output.Write(buffer, 0, count); } return output.ToArray(); } private static (byte[] Bytes, string MediaType) DecodeDataUri(string value) { var separator = value.IndexOf(','); if (separator <= 5) throw new FormatException("data:image URI 缺少内容分隔符。"); var header = value[5..separator]; var segments = header.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var mediaType = segments.FirstOrDefault() ?? string.Empty; if (!mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) throw new FormatException("data URI 不是图像类型。"); var payload = value[(separator + 1)..]; var isBase64 = segments.Skip(1).Any(segment => segment.Equals("base64", StringComparison.OrdinalIgnoreCase)); if (isBase64) { if (payload.Length > (MaximumImageBytes + 2L) / 3 * 4 + 8) throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); return (Convert.FromBase64String(payload), mediaType); } if (payload.Length > MaximumImageBytes * 3L) throw new InvalidDataException($"图标超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); return (Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload)), mediaType); } private static ImageSource DecodeBitmap(byte[] bytes) { try { using var stream = new MemoryStream(bytes, writable: false); var decoder = BitmapDecoder.Create( stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad); var frame = decoder.Frames .OrderByDescending(candidate => (long)candidate.PixelWidth * candidate.PixelHeight) .FirstOrDefault() ?? throw new InvalidDataException("图标中没有可显示的位图帧。"); if (frame.CanFreeze) frame.Freeze(); // WpfAnimatedGif 会为每个 BitmapSource 访问其 Decoder。静态位图在后台线程 // 解码后,即使帧已冻结,Decoder 仍属于创建线程,UI 线程访问时会抛出异常。 // 仅动态 GIF 需要保留 BitmapFrame;其他格式包装为 DrawingImage 后既能跨线程 // 显示,也会让动画行为直接把它赋给 Image.Source,而不再访问 Decoder。 if (decoder is GifBitmapDecoder { Frames.Count: > 1 }) return frame; var drawing = new ImageDrawing(frame, new Rect(0, 0, frame.Width, frame.Height)); if (drawing.CanFreeze) drawing.Freeze(); var image = new DrawingImage(drawing); if (image.CanFreeze) image.Freeze(); return image; } catch (Exception exception) when (exception is NotSupportedException or FileFormatException or ArgumentException) { throw new InvalidDataException("不支持的图标格式或图标内容已损坏。", exception); } } private static ImageSource DecodeSvg(byte[] bytes) { var drawingSettings = new WpfDrawingSettings { IncludeRuntime = false, // 软件图标无需将文字逐字转成路径,也无需在首次加载时重写全部路径。 // 关闭这两项可显著降低复杂 SVG 的解析与内存成本。 TextAsGeometry = false, OptimizePath = false, CanUseBitmap = false, ExternalResourcesAccessMode = ExternalResourcesAccessModes.Ignore }; using var stream = new MemoryStream(bytes, writable: false); var xmlSettings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = MaximumImageBytes }; using var xmlReader = XmlReader.Create(stream, xmlSettings); using var svgReader = new FileSvgReader(drawingSettings, isEmbedded: true); var drawing = svgReader.Read(xmlReader) ?? throw new InvalidDataException("SVG 图标没有生成可显示内容。"); if (drawing.CanFreeze) drawing.Freeze(); var image = new DrawingImage(drawing); if (image.CanFreeze) image.Freeze(); return image; } private static async Task RunDecodeAsync(Func decode) { await DecodeGate.WaitAsync().ConfigureAwait(false); try { return await Task.Run(decode).ConfigureAwait(false); } finally { DecodeGate.Release(); } } private static async Task DecodeForDisplayAsync( byte[] bytes, string? mediaType, string? sourceName, Dispatcher? displayDispatcher) { if (IsGif(bytes, mediaType, sourceName) && displayDispatcher is { HasShutdownStarted: false, HasShutdownFinished: false }) { if (displayDispatcher.CheckAccess()) return Decode(bytes, mediaType, sourceName); return await displayDispatcher .InvokeAsync( () => Decode(bytes, mediaType, sourceName), DispatcherPriority.Background) .Task .ConfigureAwait(false); } return await RunDecodeAsync(() => Decode(bytes, mediaType, sourceName)).ConfigureAwait(false); } private static bool IsGif(byte[] bytes, string? mediaType, string? sourceName) { if (string.Equals(mediaType, "image/gif", StringComparison.OrdinalIgnoreCase) || HasExtension(sourceName, ".gif")) return true; return bytes.Length >= 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F' && bytes[3] == (byte)'8' && (bytes[4] == (byte)'7' || bytes[4] == (byte)'9') && bytes[5] == (byte)'a'; } private static Dispatcher? GetDisplayDispatcher() { if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) return Dispatcher.FromThread(Thread.CurrentThread) ?? Dispatcher.CurrentDispatcher; return Application.Current?.Dispatcher; } private static bool IsSvg(byte[] bytes, string? mediaType, string? sourceName) { if (string.Equals(mediaType, "image/svg+xml", StringComparison.OrdinalIgnoreCase) || HasExtension(sourceName, ".svg") || HasExtension(sourceName, ".svgz")) return true; var prefixLength = Math.Min(bytes.Length, 4096); var prefix = Encoding.UTF8.GetString(bytes, 0, prefixLength) .TrimStart('\uFEFF', ' ', '\t', '\r', '\n'); return prefix.Contains("= 2 && bytes[0] == 0x1F && bytes[1] == 0x8B; if (!isGzip) return bytes; if (!string.Equals(mediaType, "image/svg+xml", StringComparison.OrdinalIgnoreCase) && !HasExtension(sourceName, ".svgz")) return bytes; using var input = new MemoryStream(bytes, writable: false); using var gzip = new GZipStream(input, CompressionMode.Decompress); return ReadLimited(gzip); } private static byte[] ReadLimited(Stream stream) { using var output = new MemoryStream(); var buffer = new byte[32 * 1024]; while (true) { var count = stream.Read(buffer, 0, buffer.Length); if (count == 0) break; if (output.Length + count > MaximumImageBytes) throw new InvalidDataException($"解压后的 SVG 超过 {MaximumImageBytes / 1024 / 1024} MB 限制。"); output.Write(buffer, 0, count); } return output.ToArray(); } private static bool HasExtension(string? sourceName, string extension) { if (string.IsNullOrWhiteSpace(sourceName)) return false; var path = sourceName.Split('?', '#')[0]; return path.EndsWith(extension, StringComparison.OrdinalIgnoreCase); } private static string CreateCacheKey(string value) { if (!value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)) return value; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); return $"data:{Convert.ToHexString(hash)}"; } private static HttpClient CreateImageClient() { var handler = new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, ConnectTimeout = TimeSpan.FromSeconds(8) }; var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("XFEToolBox/1.1"); return client; } }