diff --git a/Documentation/Clipboard-Behavior-Changes-9.0-to-10.0.md b/Documentation/Clipboard-Behavior-Changes-9.0-to-10.0.md new file mode 100644 index 00000000000..7f350ef05fe --- /dev/null +++ b/Documentation/Clipboard-Behavior-Changes-9.0-to-10.0.md @@ -0,0 +1,245 @@ +# Clipboard API behavior changes from .NET 9 to .NET 10 + +This document summarizes observable differences in the WPF `Clipboard`, `DataObject`, and related data-transfer APIs between the `release/9.0` and `release/10.0` branches. + +## Coordinated compatibility fix + +The coordinated WPF and `System.Private.Windows.Core` changes restore the .NET 9 behavior for the existing WPF Clipboard APIs while preserving the .NET 10 WinForms behavior: + +- WPF `Clipboard.GetDataObject()` requests the OLE proxy instead of unwrapping the original managed data object. +- WPF `Contains*` APIs use native `IsClipboardFormatAvailable` checks, including Windows-generated formats, and can again be called from an MTA thread. +- WPF `Get*` APIs retrieve through the OLE proxy, restoring Windows format synthesis for live `copy: false` data objects. +- Shared format mapping now permits platform-specific synonyms, restoring WPF's `BitmapSource` mapping without adding it to WinForms. +- WinForms continues to unwrap original managed data objects by default. + +The remaining sections describe the behavior of the unmodified `release/9.0` and `release/10.0` branches that motivated the fix. + +## Summary + +| Area | .NET 9 | .NET 10 | +|---|---|---| +| Nonpersistent ANSI text and synthesized Unicode text | `Clipboard.SetDataObject(dataObject)` exposes the system-generated `UnicodeText` format. | The default `copy: false` path preserves the live `DataObject` formats and does not expose the synthesized `UnicodeText` format. Use `copy: true` to render the data and expose system-generated formats. | +| Thread apartment requirements | `Contains*` APIs can be called from an MTA thread because they query native format availability directly. Other OLE-backed Clipboard APIs require STA. | All Clipboard APIs, including `Contains*`, require STA because format queries go through the OLE-backed clipboard data object. | +| `SetData` format validation | Rejects null and empty format names, but permits whitespace-only names. | Rejects null, empty, and whitespace-only format names. | +| Empty-format exception metadata | Some `ArgumentException` instances do not identify the `format` parameter. | Validation helpers consistently identify `format`. | +| `SetFileDropList(null)` parameter name | Reports `fileDropList`. | Reports `filePaths` through the shared clipboard implementation. | +| Bitmap mapped formats | Auto-conversion advertises `Bitmap`, `System.Drawing.Bitmap`, and `BitmapSource`. | Auto-conversion advertises `Bitmap` and `System.Drawing.Bitmap`; `BitmapSource` is no longer part of the mapped-format group. | +| Typed and safe data APIs | Not available on `Clipboard` or `DataObject`. | Adds typed retrieval, JSON data storage, `ITypedDataObject`, and `DataObjectExtensions`. | +| Nullable annotations | Clipboard getters are declared as non-nullable even when absence is represented by `null`. | Nullable return annotations reflect that audio, data objects, images, and arbitrary data may be absent. | + +## ANSI text and Unicode text synthesis + +Consider the following code: + +```csharp +DataObject dataObject = new(); +dataObject.SetText("ahoj", TextDataFormat.Text); + +Clipboard.SetDataObject(dataObject); + +bool present = Clipboard.GetDataObject().GetDataPresent("UnicodeText"); +bool hasText = Clipboard.ContainsText(); +bool hasUnicode = Clipboard.ContainsText(TextDataFormat.UnicodeText); +``` + +`DataObject.SetText(value, TextDataFormat.Text)` stores the ANSI `Text` format with managed auto-conversion disabled. + +### .NET 9 + +The OLE clipboard path exposes the system-generated Unicode representation even though the source `DataObject` did not advertise it: + +| Value | Result | +|---|---:| +| `present` | `true` | +| `hasText` | `true` | +| `hasUnicode` | `true` | + +### .NET 10 with the default `copy: false` + +The default `SetDataObject(object)` overload preserves the live, nonpersistent data object. Clipboard queries observe its native formats instead of an immediately rendered system representation: + +| Value | Result | +|---|---:| +| `present` | `false` | +| `hasText` | `false` | +| `hasUnicode` | `false` | + +`ContainsText()` checks `UnicodeText`, so it returns `false` even though the native ANSI `Text` format is present. `ContainsText(TextDataFormat.Text)` continues to return `true`. + +### .NET 10 with `copy: true` + +Rendering the data into the system clipboard causes Windows to synthesize the related text formats: + +```csharp +Clipboard.SetDataObject(dataObject, copy: true); +``` + +After rendering, `present`, `hasText`, and `hasUnicode` are all `true`. Both `GetText(TextDataFormat.Text)` and `GetText(TextDataFormat.UnicodeText)` return `"ahoj"`. + +Applications that depend on Windows-generated clipboard formats should therefore use `copy: true`. + +## Thread apartment requirements + +### .NET 9 + +The `Contains*` methods use native clipboard format checks and do not create an OLE clipboard context: + +- `ContainsAudio` +- `ContainsData` +- `ContainsFileDropList` +- `ContainsImage` +- `ContainsText` + +These methods can be called from an MTA thread. APIs that get, set, clear, flush, or inspect clipboard ownership require STA and throw `ThreadStateException` from an MTA thread. + +### .NET 10 + +Format availability is determined through `GetDataObject()` and `IDataObject.GetDataPresent`. This uses the shared OLE clipboard implementation, so the `Contains*` methods now have the same STA requirement as the other Clipboard APIs. + +Code that previously performed only a `Contains*` check on a background MTA thread must move that operation to an STA thread. + +## Format argument validation + +### `Clipboard.SetData` + +.NET 9 separately checks for null and `string.Empty`. Whitespace-only format names such as `" "` and `"\t"` pass this validation. + +.NET 10 uses `ArgumentException.ThrowIfNullOrWhiteSpace`, so null, empty, and whitespace-only format names are rejected. + +### `Clipboard.ContainsData` and `Clipboard.GetData` + +Both releases reject null and empty format names. In .NET 10, the common argument helpers consistently include `format` in the exception's `ParamName`. Some .NET 9 empty-format paths construct an `ArgumentException` without a parameter name. + +### `Clipboard.SetFileDropList` + +The null-argument exception parameter changes from `fileDropList` in .NET 9 to `filePaths` in .NET 10 because the implementation delegates validation to the shared clipboard layer. + +Applications should generally avoid depending on exception message text or parameter names unless those values are part of their explicit compatibility requirements. + +## Auto-conversion behavior + +`DataObject` has built-in synonym groups used by `GetData`, `GetDataPresent`, and `GetFormats` when auto-conversion is enabled: + +- `Text`, `UnicodeText`, and `System.String` +- `FileDrop`, `FileNameW`, and `FileName` +- Bitmap formats +- Enhanced metafile formats + +The `autoConvert` argument affects format advertisement: + +- `GetFormats(autoConvert: true)` can return every mapped synonym. +- `GetFormats(autoConvert: false)` returns only native formats. +- `GetDataPresent(format, autoConvert: true)` can report a mapped synonym. +- `GetDataPresent(format, autoConvert: false)` reports only a native format. + +For text data, the parameterless `GetData(format)` path can still search the mapped text group even when `GetDataPresent` and `GetFormats` do not advertise the mapped format. Callers that require native-format-only retrieval should use `GetData(format, autoConvert: false)`. + +### Clipboard-level auto-conversion + +The Clipboard convenience APIs intentionally request managed auto-conversion only for: + +- `DataFormats.FileDrop` +- `DataFormats.Bitmap` + +Text formats do not receive managed auto-conversion through `Clipboard.GetText`, `Clipboard.GetData`, or `Clipboard.ContainsText`. + +During .NET 10 development, typed Clipboard getters temporarily requested auto-conversion for every format. Commit `0987b406b` (`Restoring autoConvert behavior in Clipboard APIs`) restored the legacy file-drop/bitmap-only rule and made the new `Clipboard.TryGetData` overloads use `autoConvert: false` by default. + +## Bitmap mapped formats + +The .NET 9 `DataObject` mapping group includes: + +- `DataFormats.Bitmap` +- `System.Drawing.Bitmap` +- `BitmapSource` + +The .NET 10 shared data-object implementation advertises: + +- `DataFormats.Bitmap` +- `System.Drawing.Bitmap` + +Consequently, code that enumerates `GetFormats()` or calls `GetDataPresent(typeof(BitmapSource).FullName)` can observe a different result even when the clipboard still contains a retrievable image through `Clipboard.GetImage()`. + +## New .NET 10 public APIs + +.NET 10 adds typed and safer serialization alternatives. + +### `Clipboard` + +```csharp +public static bool TryGetData(string format, out T data); + +public static bool TryGetData( + string format, + Func resolver, + out T data); + +public static void SetDataAsJson(string format, T data); +``` + +### `DataObject` + +`DataObject` implements the new `ITypedDataObject` interface and adds: + +```csharp +public bool TryGetData(out T data); +public bool TryGetData(string format, out T data); +public bool TryGetData(string format, bool autoConvert, out T data); + +public bool TryGetData( + string format, + Func resolver, + bool autoConvert, + out T data); + +public void SetDataAsJson(T data); +public void SetDataAsJson(string format, T data); +``` + +`DataObjectExtensions` supplies equivalent `TryGetData` extension methods for implementations exposed only as `IDataObject`. + +These APIs allow callers to avoid unchecked object casts and provide safer alternatives to legacy formatter-based data exchange. + +## Nullable API annotations + +The following .NET 10 Clipboard methods explicitly declare nullable return values: + +```csharp +Stream? Clipboard.GetAudioStream(); +object? Clipboard.GetData(string format); +IDataObject? Clipboard.GetDataObject(); +BitmapSource? Clipboard.GetImage(); +``` + +This is primarily a source-analysis and compiler-warning improvement. It documents existing absence behavior rather than guaranteeing that every runtime path changed. + +## Behaviors intentionally preserved + +After the .NET 10 auto-conversion restoration, the following legacy behaviors remain aligned with .NET 9: + +- `Clipboard.SetText` defaults to `UnicodeText`. +- `GetText` returns `string.Empty` when the requested format is unavailable. +- Managed Clipboard auto-conversion is enabled only for file-drop and bitmap formats. +- `SetDataObject(data)` remains equivalent to `SetDataObject(data, copy: false)`. +- `copy: true` renders clipboard data and releases ownership of the source object. +- `Flush` renders delayed data and releases the current source object. +- `IsCurrent` reports whether a previously supplied OLE data object still owns the clipboard. + +## Test coverage + +The compatibility test suite added with this change includes coverage for: + +- The exact ANSI Text to UnicodeText system-synthesis scenario for a live default `copy: false` data object. +- OLE-proxy text conversion through `Clipboard.GetText` for a live `DataObject`. +- Both `autoConvert` values for the complete text mapping group. +- Every built-in mapped-format family. +- STA enforcement for OLE-backed Clipboard APIs and MTA-safe native `Contains*` checks. +- Argument validation and exception parameter names. +- `Clear`, `Flush`, `IsCurrent`, and `SetDataObject` copy semantics. +- Audio, file-drop, image, custom-format, and text round trips. + +The relevant test file is: + +```text +src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs +``` diff --git a/src/Microsoft.DotNet.Wpf/src/Extensions/PresentationFramework-SystemDrawing/SystemDrawingExtension.cs b/src/Microsoft.DotNet.Wpf/src/Extensions/PresentationFramework-SystemDrawing/SystemDrawingExtension.cs index ba29e6f5ac2..3e2683b3499 100644 --- a/src/Microsoft.DotNet.Wpf/src/Extensions/PresentationFramework-SystemDrawing/SystemDrawingExtension.cs +++ b/src/Microsoft.DotNet.Wpf/src/Extensions/PresentationFramework-SystemDrawing/SystemDrawingExtension.cs @@ -6,10 +6,12 @@ // Description: Helper methods for code that uses types from System.Drawing. using System; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Drawing.Imaging; using System.IO; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Media.Imaging; @@ -26,13 +28,47 @@ internal class SystemDrawingExtension : SystemDrawingExtensionMethods internal override bool IsMetafile(object? data) => data is Metafile; - internal override nint GetHandleFromMetafile(object? data) => data switch + internal override nint GetHandleFromMetafile(object? data) { - Metafile metafile => metafile.GetHenhmetafile(), - _ => 0 - }; + if (data is not Metafile metafile) + { + return 0; + } + + if (!metafile.GetMetafileHeader().IsEmfOrEmfPlus()) + { + return 0; + } + + using Metafile clone = (Metafile)metafile.Clone(); + return clone.GetHenhmetafile(); + } + + internal override object GetMetafileFromHemf(nint hMetafile) + { + nint copy = CopyEnhMetaFile(hMetafile, null); + if (copy == 0) + { + throw new Win32Exception(); + } + + try + { + return new Metafile(copy, deleteEmf: true); + } + catch + { + DeleteEnhMetaFile(copy); + throw; + } + } + + [DllImport("gdi32.dll", EntryPoint = "CopyEnhMetaFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern nint CopyEnhMetaFile(nint hEnhMetaFile, string? fileName); - internal override object GetMetafileFromHemf(nint hMetafile) => new Metafile(hMetafile, deleteEmf: false); + [DllImport("gdi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteEnhMetaFile(nint hEnhMetaFile); internal override object? GetBitmap(object? data) => GetBitmapImpl(data); diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Ole/WpfOleServices.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Ole/WpfOleServices.cs index 6c85994e466..e7052a501cb 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Ole/WpfOleServices.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Ole/WpfOleServices.cs @@ -19,6 +19,8 @@ namespace System.Windows.Ole; internal sealed unsafe class WpfOleServices : IOleServices { + private const string BitmapSourceFormat = "System.Windows.Media.Imaging.BitmapSource"; + // Prevent instantiation private WpfOleServices() { } @@ -38,23 +40,45 @@ public static HRESULT GetDataHere(string format, object data, FORMATETC* pformat } // Handle enhanced metafiles. - if (mediumType.HasFlag(TYMED.TYMED_ENHMF) && format.Equals(DataFormatNames.Emf)) + if (mediumType.HasFlag(TYMED.TYMED_ENHMF) + && pmedium->tymed == TYMED.TYMED_ENHMF + && IsEnhancedMetafileFormat(format)) { + pmedium->u.hEnhMetaFile = HENHMETAFILE.Null; + if (SystemDrawingHelper.IsMetafile(data)) { pmedium->u.hEnhMetaFile = SystemDrawingHelper.GetHandleFromMetafile(data); + return pmedium->u.hEnhMetaFile.IsNull ? HRESULT.E_FAIL : HRESULT.S_OK; } - else if (data is MemoryStream memoryStream && memoryStream.GetBuffer() is { } buffer && buffer.Length != 0) + + if (data is MemoryStream memoryStream) { - HENHMETAFILE hemf = PInvoke.SetEnhMetaFileBits(buffer); + byte[] buffer = memoryStream.TryGetBuffer(out ArraySegment segment) + ? segment.AsSpan().ToArray() + : memoryStream.ToArray(); + + if (buffer.Length == 0) + { + return HRESULT.E_FAIL; + } + + HENHMETAFILE hemf; + fixed (byte* bufferPointer = buffer) + { + hemf = PInvokeCore.SetEnhMetaFileBits(checked((uint)buffer.Length), bufferPointer); + } if (hemf.IsNull) { - throw new Win32Exception(); + return HRESULT.E_FAIL; } + + pmedium->u.hEnhMetaFile = hemf; + return HRESULT.S_OK; } - return HRESULT.S_OK; + return HRESULT.DV_E_TYMED; } return HRESULT.DV_E_TYMED; @@ -67,6 +91,10 @@ static HBITMAP GetCompatibleBitmap(object data) } } + public static bool IsNativeTymedSupported(string format, TYMED tymed) => + IsEnhancedMetafileFormat(format) + && tymed.HasFlag(TYMED.TYMED_ENHMF); + public static bool TryGetObjectFromDataObject( Com.IDataObject* dataObject, string format, @@ -82,11 +110,16 @@ public static bool TryGetObjectFromDataObject( mediumType = TYMED.TYMED_GDI; formatId = (ushort)CLIPBOARD_FORMAT.CF_BITMAP; } - else if (format == DataFormatNames.Emf) + else if (format.Equals(DataFormatNames.Emf, StringComparison.OrdinalIgnoreCase)) { mediumType = TYMED.TYMED_ENHMF; formatId = (ushort)CLIPBOARD_FORMAT.CF_ENHMETAFILE; } + else if (format.Equals(DataFormatNames.BinaryFormatMetafile, StringComparison.OrdinalIgnoreCase)) + { + mediumType = TYMED.TYMED_ENHMF; + formatId = checked((ushort)DataFormats.GetDataFormat(format).Id); + } else { return false; @@ -100,22 +133,22 @@ public static bool TryGetObjectFromDataObject( tymed = (uint)mediumType }; - HRESULT result = dataObject->QueryGetData(formatEtc); + HRESULT result = ClipboardRetry.QueryGetData(dataObject, formatEtc); if (result.Failed) { return false; } - result = dataObject->GetData(formatEtc, out STGMEDIUM medium); + result = ClipboardRetry.GetData(dataObject, formatEtc, out STGMEDIUM medium); - try + if (result.Failed) { - if (result.Failed) - { - return false; - } + return false; + } + try + { if (mediumType == TYMED.TYMED_GDI) { // Get the bitmap from the handle of bitmap. @@ -133,6 +166,11 @@ public static bool TryGetObjectFromDataObject( } else { + if (medium.tymed != TYMED.TYMED_ENHMF || medium.u.hEnhMetaFile.IsNull) + { + return false; + } + // Get the metafile object form the enhanced metafile handle. object metafile = SystemDrawingHelper.GetMetafileFromHemf((HENHMETAFILE)(nint)medium.hGlobal); if (metafile is T t) @@ -140,6 +178,8 @@ public static bool TryGetObjectFromDataObject( data = t; return true; } + + (metafile as IDisposable)?.Dispose(); } } finally @@ -150,12 +190,31 @@ public static bool TryGetObjectFromDataObject( return false; } + private static bool IsEnhancedMetafileFormat(string format) => + format.Equals(DataFormatNames.Emf, StringComparison.OrdinalIgnoreCase) + || format.Equals(DataFormatNames.BinaryFormatMetafile, StringComparison.OrdinalIgnoreCase); + public static bool AllowTypeWithoutResolver() { // Image is a special case because we are reading bitmaps directly from the SerializationRecord. return typeof(T).FullName.Equals("System.Drawing.Image"); } + public static void AddMappedFormats(string format, ICollection formats) + { + // BitmapSource is a WPF-only synonym that was part of the .NET 9 DataObject mapping. Keep it in the WPF + // platform hook so the shared WinForms mapping does not advertise a type it cannot reference. + if (format is DataFormatNames.Bitmap or DataFormatNames.BinaryFormatBitmap) + { + formats.Add(BitmapSourceFormat); + } + else if (format == BitmapSourceFormat) + { + formats.Add(DataFormatNames.Bitmap); + formats.Add(DataFormatNames.BinaryFormatBitmap); + } + } + public static bool IsValidTypeForFormat(Type type, string format) => format switch { DataFormatNames.Bitmap or DataFormatNames.BinaryFormatBitmap => diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/clipboard.cs b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/clipboard.cs index 0133ff42fa6..9f7a8074c8e 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/clipboard.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/clipboard.cs @@ -209,7 +209,11 @@ public static void SetText(string text, TextDataFormat format) /// public static IDataObject? GetDataObject() { - ClipboardCore.GetDataObject(out IDataObject? dataObject).ThrowOnFailure(); + // .NET 9 returned the OLE view of clipboard data. Keep using the proxy so Windows-generated formats and + // delayed rendering remain observable instead of exposing the original managed object's native formats. + ClipboardCore.GetDataObject( + out IDataObject? dataObject, + unwrapUserDataObject: false).ThrowOnFailure(); return dataObject; } @@ -261,8 +265,30 @@ public static void SetDataObject(object data, bool copy) /// /// Query the specified data format from Clipboard. /// - private static bool ContainsDataInternal(string format) => - GetDataObject() is { } dataObject && dataObject.GetDataPresent(format, IsDataFormatAutoConvert(format)); + private static bool ContainsDataInternal(string format) + { + List formats = [format]; + + if (IsDataFormatAutoConvert(format)) + { + // File-drop and bitmap queries historically considered their mapped native and platform formats. + ClipboardCore.AddMappedFormats(format, formats); + } + + foreach (string availableFormat in formats) + { + uint formatId = unchecked((uint)DataFormats.GetDataFormat(availableFormat).Id); + + // Query Windows directly rather than opening the OLE data object. This reports formats Windows can + // synthesize (for example UnicodeText from Text) and preserves the legacy MTA-safe Contains behavior. + if (ClipboardCore.IsClipboardFormatAvailable(formatId)) + { + return true; + } + } + + return false; + } /// /// Get the specified format from Clipboard. diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs index 2b7ed20568f..04e1d3a9e86 100644 --- a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/ClipboardTests.cs @@ -1,10 +1,16 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Specialized; +using System.ComponentModel; +using System.Drawing.Imaging; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Imaging; +using Moq; using Color = System.Windows.Media.Color; namespace System.Windows; @@ -13,8 +19,17 @@ namespace System.Windows; // collisions with other tests. We also retry as we cannot control other processes that may be using the clipboard. [Collection("Sequential")] [UISettings(MaxAttempts = 3)] -public class ClipboardTests +public class ClipboardTests : IDisposable { + private static readonly Lazy s_wpfGraphics = new( + () => NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, "wpfgfx_cor3.dll"))); + + // Isolate each STA test from clipboard ownership and delayed-rendering state left by earlier tests. + public ClipboardTests() => ClearClipboardIfSta(); + + // Release the managed clipboard owner before the UI test runner tears down this test's STA. + public void Dispose() => ClearClipboardIfSta(); + [WpfFact] public void SetText_InvokeString_GetReturnsExpected() { @@ -101,26 +116,17 @@ public void SetData_Invoke_GetReturnsExpected(string format, object? data) } [WpfTheory] - // These three fail in WinForms, should probably fail in WPF as well. - // [InlineData("")] - // [InlineData(" ")] - // [InlineData("\t")] - [InlineData(null)] - public void SetData_EmptyOrWhitespaceFormat_ThrowsArgumentException(string? format) - { - Action action = () => Clipboard.SetData(format!, "data"); + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void SetData_EmptyOrWhitespaceFormat_ThrowsArgumentException(string format) + { + Action action = () => Clipboard.SetData(format, "data"); action.Should().Throw().WithParameterName("format"); } [WpfFact] public void SetData_Null_Throws() - { - Action action = () => Clipboard.SetData("MyData", data: null!); - action.Should().Throw().WithParameterName("data"); - } - - [WpfFact] - public void SetData_NullData_ThrowsArgumentNullException() { Action action = () => Clipboard.SetData("MyData", data: null!); action.Should().Throw().WithParameterName("data"); @@ -225,10 +231,11 @@ public void SetDataObject_WithMultipleData(bool copy) result2.Should().Be(testData2); } + // Clipboard getters should observe the Windows conversions exposed by the OLE proxy, matching .NET 9. [WpfTheory] [InlineData(TextDataFormat.Text, TextDataFormat.UnicodeText)] [InlineData(TextDataFormat.UnicodeText, TextDataFormat.Text)] - public void GetText_AutoConvertibleFormat_DoesNotAutoConvert( + public void GetText_AutoConvertibleFormat_UsesOleFormatConversion( TextDataFormat sourceFormat, TextDataFormat requestedFormat) { @@ -243,7 +250,7 @@ public void GetText_AutoConvertibleFormat_DoesNotAutoConvert( Clipboard.SetDataObject(dataObject); - Clipboard.GetText(requestedFormat).Should().BeEmpty(); + Clipboard.GetText(requestedFormat).Should().Be(text); } [WpfFact] @@ -270,4 +277,1236 @@ public void SetData_Text_Format_AllUpper() Clipboard.GetData("System.String").Should().BeNull(); Clipboard.GetData("TEXT").Should().BeNull(); } + + [WpfFact] + public void FormatTakingApis_NullFormat_ThrowArgumentNullException() + { + Action containsData = () => Clipboard.ContainsData(null!); + Action getData = () => Clipboard.GetData(null!); + Action setData = () => Clipboard.SetData(null!, "value"); + + containsData.Should().Throw().WithParameterName("format"); + getData.Should().Throw().WithParameterName("format"); + setData.Should().Throw().WithParameterName("format"); + } + + // SetData empty-format validation is covered above. + [WpfFact] + public void ContainsDataAndGetData_EmptyFormat_ThrowArgumentException() + { + Action containsData = () => Clipboard.ContainsData(string.Empty); + Action getData = () => Clipboard.GetData(string.Empty); + + containsData.Should().Throw().WithParameterName("format"); + getData.Should().Throw().WithParameterName("format"); + } + + [WpfFact] + public void SetApis_NullData_ThrowArgumentNullException() + { + Action setImage = () => Clipboard.SetImage(null!); + Action setText = () => Clipboard.SetText(null!); + Action setFormattedText = () => Clipboard.SetText(null!, TextDataFormat.Text); + Action setDataObject = () => Clipboard.SetDataObject(null!); + Action setDataObjectWithoutCopy = () => Clipboard.SetDataObject(null!, copy: false); + + setImage.Should().Throw().WithParameterName("image"); + setText.Should().Throw().WithParameterName("text"); + setFormattedText.Should().Throw().WithParameterName("text"); + setDataObject.Should().Throw().WithParameterName("data"); + setDataObjectWithoutCopy.Should().Throw().WithParameterName("data"); + } + + [WpfTheory] + [InlineData(-1)] + [InlineData(6)] + [InlineData(int.MaxValue)] + public void TextFormatApis_InvalidFormat_ThrowInvalidEnumArgumentException(int value) + { + TextDataFormat format = (TextDataFormat)value; + Action containsText = () => Clipboard.ContainsText(format); + Action getText = () => Clipboard.GetText(format); + Action setText = () => Clipboard.SetText("value", format); + + containsText.Should().Throw().WithParameterName("format"); + getText.Should().Throw().WithParameterName("format"); + setText.Should().Throw().WithParameterName("format"); + } + + [WpfFact] + public void IsCurrent_NullData_ThrowsArgumentNullException() + { + Action action = () => Clipboard.IsCurrent(null!); + action.Should().Throw().WithParameterName("data"); + } + + [WpfFact] + public void IsCurrent_ManagedIDataObject_ReturnsFalseWithoutOleCall() + { + IDataObject dataObject = new Mock(MockBehavior.Strict).Object; + + Clipboard.IsCurrent(dataObject).Should().BeFalse(); + } + + [WpfFact] + public void SetFileDropList_NullPath_ThrowsArgumentException() + { + StringCollection paths = new() { null! }; + Action action = () => Clipboard.SetFileDropList(paths); + + action.Should().Throw(); + } + + // OLE-backed Clipboard APIs require STA, so exercise MTA explicitly without changing the WpfFact convention. + [WpfFact] + public void OleBackedClipboardApis_MtaThread_ThrowThreadStateException() + { + EnsureWpfGraphicsLoaded(); + using MemoryStream audioStream = new([1, 2, 3]); + DataObject dataObject = new("format", "value"); + StringCollection fileDropList = new() { Path.Combine(Path.GetTempPath(), "clipboard.txt") }; + WriteableBitmap image = new(1, 1, 96, 96, PixelFormats.Bgra32, palette: null); + Action[] actions = + [ + Clipboard.Clear, + Clipboard.Flush, + () => Clipboard.GetAudioStream(), + () => Clipboard.GetDataObject(), + () => Clipboard.GetData("format"), + () => Clipboard.GetFileDropList(), + () => Clipboard.GetImage(), + () => Clipboard.GetText(), + () => Clipboard.GetText(TextDataFormat.Text), + () => Clipboard.SetAudio([1, 2, 3]), + () => Clipboard.SetAudio(audioStream), + () => Clipboard.SetData("format", "value"), + () => Clipboard.SetDataObject(dataObject), + () => Clipboard.SetDataObject(dataObject, copy: true), + () => Clipboard.SetFileDropList(fileDropList), + () => Clipboard.SetImage(image), + () => Clipboard.SetText("value"), + () => Clipboard.SetText("value", TextDataFormat.Text), + () => Clipboard.IsCurrent(dataObject), + ]; + + foreach (Action action in actions) + { + Exception? exception = null; + Thread thread = new(() => exception = Xunit.Record.Exception(action)); + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + thread.Join(); + + exception.Should().BeOfType(); + } + } + + // Contains APIs use native format availability and do not require an OLE apartment. + [WpfFact] + public void ContainsApis_MtaThread_DoNotRequireSta() + { + Action[] actions = + [ + () => Clipboard.ContainsAudio(), + () => Clipboard.ContainsData("format"), + () => Clipboard.ContainsFileDropList(), + () => Clipboard.ContainsImage(), + () => Clipboard.ContainsText(), + () => Clipboard.ContainsText(TextDataFormat.Text), + ]; + + foreach (Action action in actions) + { + Exception? exception = null; + Thread thread = new(() => exception = Xunit.Record.Exception(action)); + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + thread.Join(); + + exception.Should().BeNull(); + } + } + + [WpfFact] + public void Clear_PopulatedClipboard_ResetsAllTypedViews() + { + Clipboard.SetText("value"); + + Clipboard.Clear(); + + IDataObject dataObject = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + RetryClipboardAccess(dataObject.GetFormats).Should().BeEmpty(); + Clipboard.ContainsAudio().Should().BeFalse(); + Clipboard.ContainsData(DataFormats.UnicodeText).Should().BeFalse(); + Clipboard.ContainsFileDropList().Should().BeFalse(); + Clipboard.ContainsImage().Should().BeFalse(); + Clipboard.ContainsText().Should().BeFalse(); + Clipboard.ContainsText(TextDataFormat.UnicodeText).Should().BeFalse(); + Clipboard.GetAudioStream().Should().BeNull(); + Clipboard.GetData(DataFormats.UnicodeText).Should().BeNull(); + Clipboard.GetFileDropList().Cast().Should().BeEmpty(); + Clipboard.GetImage().Should().BeNull(); + Clipboard.GetText().Should().BeEmpty(); + Clipboard.GetText(TextDataFormat.UnicodeText).Should().BeEmpty(); + } + + // Verifies the production Clipboard retry loop survives temporary native clipboard ownership by another thread. + [WpfFact] + public void Clear_ClipboardTemporarilyLocked_RetriesUntilAvailable() + { + using ManualResetEventSlim clipboardOpened = new(); + using ManualResetEventSlim releaseClipboard = new(); + Exception? lockerException = null; + + Thread locker = new(() => + { + bool opened = false; + try + { + opened = OpenClipboardWithRetry(); + clipboardOpened.Set(); + releaseClipboard.Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception exception) + { + lockerException = exception; + clipboardOpened.Set(); + } + finally + { + if (opened && !CloseClipboard()) + { + lockerException ??= new Win32Exception(Marshal.GetLastWin32Error()); + } + } + }) + { + IsBackground = true, + Name = $"{nameof(ClipboardTests)} native clipboard owner" + }; + locker.SetApartmentState(ApartmentState.MTA); + locker.Start(); + + clipboardOpened.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); + lockerException.Should().BeNull(); + + Thread releaser = new(() => + { + Thread.Sleep(250); + releaseClipboard.Set(); + }) + { + IsBackground = true, + Name = $"{nameof(ClipboardTests)} native clipboard releaser" + }; + releaser.Start(); + + try + { + // This call must initially receive CLIPBRD_E_CANT_OPEN and then succeed after the locker releases. + Clipboard.Clear(); + } + finally + { + releaseClipboard.Set(); + releaser.Join(); + locker.Join(); + } + + lockerException.Should().BeNull(); + } + + [WpfFact] + public void SetData_CustomFormat_RoundTripsThroughIDataObject() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + const string value = "custom clipboard value"; + + try + { + Clipboard.SetData(format, value); + DataFormat registeredFormat = DataFormats.GetDataFormat(format); + + Clipboard.ContainsData(format).Should().BeTrue(); + Clipboard.ContainsData(format.ToUpperInvariant()).Should().BeTrue(); + Clipboard.GetData(format).Should().Be(value); + Clipboard.GetData(format.ToUpperInvariant()).Should().Be(value); + registeredFormat.Name.Should().Be(format); + DataFormats.GetDataFormat(registeredFormat.Id).Should().BeSameAs(registeredFormat); + DataFormats.GetDataFormat(format.ToUpperInvariant()).Should().BeSameAs(registeredFormat); + + IDataObject dataObject = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + RetryClipboardAccess(() => dataObject.GetDataPresent(format)).Should().BeTrue(); + RetryClipboardAccess(() => dataObject.GetDataPresent(format, autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess(() => dataObject.GetData(format)).Should().Be(value); + RetryClipboardAccess(() => dataObject.GetData(format, autoConvert: false)).Should().Be(value); + RetryClipboardAccess(dataObject.GetFormats).Should().Contain(format); + RetryClipboardAccess(() => dataObject.GetFormats(autoConvert: false)).Should().Contain(format); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetDataObject_DefaultAndFalseOverloads_PreserveLiveDataObject() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "first"); + + try + { + Clipboard.SetDataObject(dataObject); + + Clipboard.IsCurrent(dataObject).Should().BeTrue(); + dataObject.SetData(format, "first changed"); + Clipboard.GetData(format).Should().Be("first changed"); + + Clipboard.Clear(); + dataObject.SetData(format, "second"); + Clipboard.SetDataObject(dataObject, copy: false); + + Clipboard.IsCurrent(dataObject).Should().BeTrue(); + dataObject.SetData(format, "second changed"); + Clipboard.GetData(format).Should().Be("second changed"); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetDataObject_ReplacingCurrentObject_UpdatesIsCurrent() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject first = new(format, "first"); + DataObject second = new(format, "second"); + + try + { + Clipboard.SetDataObject(first, copy: false); + Clipboard.IsCurrent(first).Should().BeTrue(); + + Clipboard.SetDataObject(second, copy: false); + + Clipboard.IsCurrent(first).Should().BeFalse(); + Clipboard.IsCurrent(second).Should().BeTrue(); + Clipboard.GetData(format).Should().Be("second"); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetDataObject_LiveDataObject_PreservesIDataObjectOverloads() + { + string nativeFormat = $"WPF ClipboardTests {Guid.NewGuid():N}"; + const int typedValue = 42; + DataObject source = new(); + source.SetData("typed string"); + source.SetData(typeof(int), typedValue); + source.SetData(nativeFormat, "native", autoConvert: false); + + try + { + Clipboard.SetDataObject(source, copy: false); + + IDataObject result = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + result.GetDataPresent(typeof(string)).Should().BeTrue(); + result.GetDataPresent(typeof(int)).Should().BeTrue(); + result.GetDataPresent(nativeFormat).Should().BeTrue(); + result.GetDataPresent(nativeFormat, autoConvert: false).Should().BeTrue(); + result.GetData(typeof(string)).Should().Be("typed string"); + result.GetData(typeof(int)).Should().Be(typedValue); + result.GetData(nativeFormat).Should().Be("native"); + result.GetData(nativeFormat, autoConvert: false).Should().Be("native"); + result.GetFormats().Should().Contain(nativeFormat); + result.GetFormats(autoConvert: false).Should().Contain(nativeFormat); + } + finally + { + Clipboard.Clear(); + } + } + + // The system synthesizes UnicodeText from ANSI Text even when source auto-conversion is disabled. + [WpfFact] + public void SetDataObject_AnsiText_ExposesSystemGeneratedUnicodeTextToClipboardApis() + { + const string value = "ahoj"; + DataObject source = new(); + source.SetText(value, TextDataFormat.Text); + + try + { + source.GetDataPresent(DataFormats.UnicodeText).Should().BeFalse(); + source.GetDataPresent(DataFormats.UnicodeText, autoConvert: false).Should().BeFalse(); + + Clipboard.SetDataObject(source); + + IDataObject result = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + result.Should().NotBeSameAs(source); + RetryClipboardAccess(() => result.GetDataPresent(DataFormats.UnicodeText)).Should().BeTrue(); + RetryClipboardAccess(() => result.GetDataPresent(DataFormats.UnicodeText, autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess(() => result.GetData(DataFormats.UnicodeText)).Should().Be(value); + RetryClipboardAccess(() => result.GetData(DataFormats.UnicodeText, autoConvert: false)).Should().Be(value); + Clipboard.ContainsText().Should().BeTrue(); + Clipboard.ContainsText(TextDataFormat.Text).Should().BeTrue(); + Clipboard.ContainsText(TextDataFormat.UnicodeText).Should().BeTrue(); + Clipboard.GetText().Should().Be(value); + Clipboard.GetText(TextDataFormat.Text).Should().Be(value); + Clipboard.GetText(TextDataFormat.UnicodeText).Should().Be(value); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfTheory] + [InlineData(true)] + [InlineData(false)] + public void DataObject_TextAutoConvert_ControlsMappedFormatAdvertisement(bool autoConvert) + { + const string value = "text conversion verification"; + string[] mappedFormats = + [ + DataFormats.Text, + DataFormats.UnicodeText, + DataFormats.StringFormat, + ]; + DataObject dataObject = new(); + dataObject.SetData(DataFormats.Text, value, autoConvert); + + foreach (string format in mappedFormats) + { + bool expected = autoConvert || format == DataFormats.Text; + + dataObject.GetDataPresent(format).Should().Be(expected); + dataObject.GetDataPresent(format, autoConvert: false).Should().Be(format == DataFormats.Text); + dataObject.GetData(format).Should().Be(value); + dataObject.GetData(format, autoConvert: false).Should().Be(format == DataFormats.Text ? value : null); + } + + string[] expectedFormats = autoConvert ? mappedFormats : [DataFormats.Text]; + dataObject.GetFormats().Should().BeEquivalentTo(expectedFormats); + dataObject.GetFormats(autoConvert: false).Should().Equal(DataFormats.Text); + } + + [WpfFact] + public void DataObject_AutoConvert_ControlsAllBuiltInFormatMappings() + { + string[][] formatGroups = + [ + [DataFormats.Text, DataFormats.UnicodeText, DataFormats.StringFormat], + [DataFormats.FileDrop, "FileNameW", "FileName"], + [DataFormats.Bitmap, typeof(System.Drawing.Bitmap).FullName!, typeof(BitmapSource).FullName!], + [DataFormats.EnhancedMetafile, typeof(System.Drawing.Imaging.Metafile).FullName!], + ]; + + foreach (string[] formatGroup in formatGroups) + { + foreach (string sourceFormat in formatGroup) + { + foreach (bool autoConvert in new[] { false, true }) + { + DataObject dataObject = new(); + dataObject.SetData(sourceFormat, "value", autoConvert); + string[] expectedFormats = autoConvert ? formatGroup : [sourceFormat]; + + dataObject.GetFormats().Should().BeEquivalentTo(expectedFormats); + dataObject.GetFormats(autoConvert: false).Should().Equal(sourceFormat); + + foreach (string targetFormat in formatGroup) + { + dataObject.GetDataPresent(targetFormat).Should().Be(autoConvert || targetFormat == sourceFormat); + dataObject.GetDataPresent(targetFormat, autoConvert: false).Should().Be(targetFormat == sourceFormat); + } + } + } + } + } + + // Verifies WPF's platform mapping restores BitmapSource as a Bitmap synonym without changing shared mappings. + [WpfFact] + public void SetDataObject_BitmapSourceFormat_UsesLegacyBitmapAutoConversion() + { + EnsureWpfGraphicsLoaded(); + byte[] pixels = [0x10, 0x20, 0x30, 0xFF]; + WriteableBitmap sourceImage = new(1, 1, 96, 96, PixelFormats.Bgra32, palette: null); + sourceImage.WritePixels(new Int32Rect(0, 0, 1, 1), pixels, stride: 4, offset: 0); + DataObject source = new(); + source.SetData(typeof(BitmapSource), sourceImage); + + try + { + Clipboard.SetDataObject(source); + + Clipboard.ContainsImage().Should().BeTrue(); + Clipboard.GetData(DataFormats.Bitmap).Should().BeAssignableTo(); + BitmapSource result = Clipboard.GetImage().Should().BeAssignableTo().Subject; + byte[] resultPixels = new byte[4]; + new FormatConvertedBitmap(result, PixelFormats.Bgra32, null, 0) + .CopyPixels(resultPixels, stride: 4, offset: 0); + resultPixels.Should().Equal(pixels); + + IDataObject dataObject = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + dataObject.GetFormats().Should().Contain( + DataFormats.Bitmap, + typeof(System.Drawing.Bitmap).FullName!, + typeof(BitmapSource).FullName!); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetDataObject_RawObject_WrapsItInADataObject() + { + const string value = "raw string"; + + try + { + Clipboard.SetDataObject(value); + + Clipboard.ContainsText().Should().BeTrue(); + Clipboard.GetText().Should().Be(value); + IDataObject result = Clipboard.GetDataObject().Should().BeAssignableTo().Subject; + result.GetDataPresent(typeof(string)).Should().BeTrue(); + result.GetData(typeof(string)).Should().Be(value); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void Flush_NonPersistentDataObject_RendersDataAndReleasesSource() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "persisted"); + + try + { + Clipboard.SetDataObject(dataObject, copy: false); + Clipboard.IsCurrent(dataObject).Should().BeTrue(); + + Clipboard.Flush(); + + Clipboard.IsCurrent(dataObject).Should().BeFalse(); + Clipboard.GetData(format).Should().Be("persisted"); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetDataObject_CopyTrue_RendersDataAndReleasesSource() + { + string format = $"WPF ClipboardTests {Guid.NewGuid():N}"; + DataObject dataObject = new(format, "persisted"); + + try + { + Clipboard.SetDataObject(dataObject, copy: true); + dataObject.SetData(format, "changed after copy"); + + Clipboard.IsCurrent(dataObject).Should().BeFalse(); + Clipboard.GetData(format).Should().Be("persisted"); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetText_DefaultOverload_RoundTripsUnicodeText() + { + const string value = "Clipboard \u03A9 \U0001F600"; + + try + { + Clipboard.SetText(value); + + Clipboard.ContainsText().Should().BeTrue(); + Clipboard.ContainsText(TextDataFormat.UnicodeText).Should().BeTrue(); + Clipboard.ContainsData(DataFormats.UnicodeText).Should().BeTrue(); + Clipboard.GetText().Should().Be(value); + Clipboard.GetText(TextDataFormat.UnicodeText).Should().Be(value); + Clipboard.GetData(DataFormats.UnicodeText).Should().Be(value); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfTheory] + [InlineData(TextDataFormat.Text)] + [InlineData(TextDataFormat.UnicodeText)] + [InlineData(TextDataFormat.Rtf)] + [InlineData(TextDataFormat.Html)] + [InlineData(TextDataFormat.CommaSeparatedValue)] + [InlineData(TextDataFormat.Xaml)] + public void SetText_AllSupportedFormats_RoundTrip(TextDataFormat format) + { + string value = $"text for {format}"; + string dataFormat = GetDataFormat(format); + + try + { + Clipboard.SetText(value, format); + + Clipboard.ContainsText(format).Should().BeTrue(); + Clipboard.ContainsData(dataFormat).Should().BeTrue(); + Clipboard.GetText(format).Should().Be(value); + Clipboard.GetData(dataFormat).Should().Be(value); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetText_EmptyString_RoundTrips() + { + try + { + Clipboard.SetText(string.Empty); + + Clipboard.ContainsText().Should().BeTrue(); + Clipboard.GetText().Should().BeEmpty(); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetAudio_ByteArray_CopiesAndRoundTripsData() + { + byte[] source = [1, 2, 3, 4, 5]; + + try + { + Clipboard.SetAudio(source); + source[0] = 42; + + Clipboard.ContainsAudio().Should().BeTrue(); + Clipboard.ContainsData(DataFormats.WaveAudio).Should().BeTrue(); + ReadAllBytes(Clipboard.GetAudioStream()).Should().Equal(1, 2, 3, 4, 5); + Stream waveData = Clipboard.GetData(DataFormats.WaveAudio).Should().BeAssignableTo().Subject; + ReadAllBytes(waveData).Should().Equal(1, 2, 3, 4, 5); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetAudio_Stream_CopiesWholeStreamAndSurvivesSourceDisposal() + { + MemoryStream source = new([6, 7, 8, 9]) + { + Position = 2 + }; + + try + { + Clipboard.SetAudio(source); + source.Dispose(); + + Clipboard.ContainsAudio().Should().BeTrue(); + ReadAllBytes(Clipboard.GetAudioStream()).Should().Equal(6, 7, 8, 9); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetFileDropList_CopiesAndRoundTripsPaths() + { + string first = Path.Combine(Path.GetTempPath(), $"clipboard-{Guid.NewGuid():N}-1.txt"); + string second = Path.Combine(Path.GetTempPath(), $"clipboard-{Guid.NewGuid():N}-2.txt"); + StringCollection source = new() { first, second }; + + try + { + Clipboard.SetFileDropList(source); + source.Clear(); + + Clipboard.ContainsFileDropList().Should().BeTrue(); + Clipboard.ContainsData(DataFormats.FileDrop).Should().BeTrue(); + StringCollection returned = Clipboard.GetFileDropList(); + returned.Cast().Should().Equal(first, second); + Clipboard.GetData(DataFormats.FileDrop).Should().BeOfType().Which.Should().Equal(first, second); + + returned[0] = "changed"; + returned.RemoveAt(1); + + Clipboard.GetFileDropList().Cast().Should().Equal(first, second); + } + finally + { + Clipboard.Clear(); + } + } + + [WpfFact] + public void SetImage_CopiesAndRoundTripsPixels() + { + EnsureWpfGraphicsLoaded(); + byte[] originalPixels = + [ + 0x10, 0x20, 0x30, 0xFF, + 0x40, 0x50, 0x60, 0xFF, + ]; + WriteableBitmap source = new(2, 1, 96, 96, PixelFormats.Bgra32, palette: null); + source.WritePixels(new Int32Rect(0, 0, 2, 1), originalPixels, stride: 8, offset: 0); + + try + { + Clipboard.SetImage(source); + source.WritePixels( + new Int32Rect(0, 0, 2, 1), + new byte[] + { + 0x70, 0x80, 0x90, 0xFF, + 0xA0, 0xB0, 0xC0, 0xFF, + }, + stride: 8, + offset: 0); + + Clipboard.ContainsImage().Should().BeTrue(); + Clipboard.ContainsData(DataFormats.Bitmap).Should().BeTrue(); + BitmapSource image = Clipboard.GetImage().Should().BeAssignableTo().Subject; + image.PixelWidth.Should().Be(2); + image.PixelHeight.Should().Be(1); + image.Should().NotBeSameAs(source); + BitmapSource normalized = new FormatConvertedBitmap(image, PixelFormats.Bgr24, null, 0); + byte[] actualPixels = new byte[6]; + normalized.CopyPixels(actualPixels, stride: 6, offset: 0); + actualPixels.Should().Equal(0x10, 0x20, 0x30, 0x40, 0x50, 0x60); + Clipboard.GetData(DataFormats.Bitmap).Should().BeAssignableTo(); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies delayed native rendering returns an independent, renderable EMF without invalidating the source. + [WpfFact] + public void SetDataObject_Metafile_CopyFalse_RoundTripsWhileSourceIsAlive() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + + Clipboard.IsCurrent(sourceDataObject).Should().BeTrue(); + Clipboard.ContainsData(DataFormats.EnhancedMetafile).Should().BeTrue(); + + DataObject clipboardDataObject = RetryClipboardAccess(Clipboard.GetDataObject) + .Should().BeOfType().Subject; + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + typeof(Metafile).FullName!, + autoConvert: true)).Should().BeTrue(); + + using (Metafile legacyResult = RetryClipboardAccess( + () => Clipboard.GetData(DataFormats.EnhancedMetafile)) + .Should().BeOfType().Subject) + { + legacyResult.Should().NotBeSameAs(source); + EmfTestData.AssertValid(legacyResult); + } + + EmfTestData.AssertValid(source); + } + finally + { + Clipboard.Clear(); + } + } + + // Verifies Flush persists renderable EMF data after both clipboard ownership and the source object are released. + [WpfFact] + public void SetDataObject_Metafile_CopyFalseFlush_PersistsAfterSourceDisposal() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + Clipboard.IsCurrent(sourceDataObject).Should().BeTrue(); + + RetryClipboardAccess( + () => + { + Clipboard.Flush(); + return true; + }).Should().BeTrue(); + + Clipboard.IsCurrent(sourceDataObject).Should().BeFalse(); + source.Dispose(); + source = null; + + using Metafile legacyResult = RetryClipboardAccess( + () => Clipboard.GetData(DataFormats.EnhancedMetafile)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(legacyResult); + + using Metafile typedResult = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + EmfTestData.AssertValid(typedResult); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Verifies copy:true eagerly persists a renderable EMF that no longer depends on the source Metafile. + [WpfFact] + public void SetDataObject_Metafile_CopyTrue_PersistsAfterSourceDisposal() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + Clipboard.IsCurrent(sourceDataObject).Should().BeFalse(); + + source.Dispose(); + source = null; + + Clipboard.ContainsData(DataFormats.EnhancedMetafile).Should().BeTrue(); + using Metafile legacyResult = RetryClipboardAccess( + () => Clipboard.GetData(DataFormats.EnhancedMetafile)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(legacyResult); + + using Metafile typedResult = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + EmfTestData.AssertValid(typedResult); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Verifies the CLR Metafile alias is available only through auto-conversion when the source disables mappings. + [WpfFact] + public void SetDataObject_Metafile_MappedFormatRequiresAutoConvert() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + source.Dispose(); + source = null; + + DataObject clipboardDataObject = RetryClipboardAccess(Clipboard.GetDataObject) + .Should().BeOfType().Subject; + string mappedFormat = typeof(Metafile).FullName!; + + Clipboard.ContainsData(DataFormats.EnhancedMetafile).Should().BeTrue(); + Clipboard.ContainsData(mappedFormat).Should().BeFalse(); + + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: false)).Should().BeFalse(); + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: true)).Should().BeTrue(); + + using Metafile exactResult = RetryClipboardAccess( + () => clipboardDataObject.GetData( + DataFormats.EnhancedMetafile, + autoConvert: false)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(exactResult); + + using Metafile mappedResult = RetryClipboardAccess( + () => clipboardDataObject.GetData( + mappedFormat, + autoConvert: true)) + .Should().BeOfType().Subject; + mappedResult.Should().NotBeSameAs(exactResult); + EmfTestData.AssertValid(mappedResult); + + using Metafile convertedResult = GetTypedDataObjectMetafile( + clipboardDataObject, + mappedFormat, + autoConvert: true); + EmfTestData.AssertValid(convertedResult); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Verifies an auto-convertible EMF advertises and retrieves both native and CLR mapped formats. + [WpfFact] + public void SetDataObject_Metafile_AutoConvertTrue_ExposesMappedIDataObjectFormat() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: true); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: false); + Clipboard.IsCurrent(sourceDataObject).Should().BeTrue(); + + string mappedFormat = typeof(Metafile).FullName!; + Clipboard.ContainsData(DataFormats.EnhancedMetafile).Should().BeTrue(); + Clipboard.ContainsData(mappedFormat).Should().BeTrue(); + + DataObject clipboardDataObject = RetryClipboardAccess(Clipboard.GetDataObject) + .Should().BeOfType().Subject; + + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + DataFormats.EnhancedMetafile, + autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: false)).Should().BeTrue(); + RetryClipboardAccess( + () => clipboardDataObject.GetDataPresent( + mappedFormat, + autoConvert: true)).Should().BeTrue(); + + using Metafile nativeResult = RetryClipboardAccess( + () => clipboardDataObject.GetData( + DataFormats.EnhancedMetafile, + autoConvert: false)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(nativeResult); + + using Metafile mappedResult = RetryClipboardAccess( + () => clipboardDataObject.GetData( + mappedFormat, + autoConvert: false)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(mappedResult); + + using Metafile clipboardResult = RetryClipboardAccess( + () => Clipboard.GetData(DataFormats.EnhancedMetafile)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(clipboardResult); + + using Metafile mappedClipboardResult = RetryClipboardAccess( + () => Clipboard.GetData(mappedFormat)) + .Should().BeOfType().Subject; + EmfTestData.AssertValid(mappedClipboardResult); + + using Metafile typedResult = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + EmfTestData.AssertValid(typedResult); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Verifies repeated EMF reads return independent native copies whose disposal does not affect each other. + [WpfFact] + public void SetDataObject_Metafile_RepeatedGetsReturnIndependentlyDisposableResults() + { + Metafile? source = EmfTestData.CreateMetafile(); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + source.Dispose(); + source = null; + + Metafile? first = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + Metafile? second = null; + try + { + second = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + second.Should().NotBeSameAs(first); + EmfTestData.AssertValid(first); + + first.Dispose(); + first = null; + EmfTestData.AssertValid(second); + } + finally + { + first?.Dispose(); + second?.Dispose(); + } + } + finally + { + Clipboard.Clear(); + } + } + finally + { + source?.Dispose(); + } + } + + // Verifies a non-exposable EMF stream is fully rendered despite its current position and survives source disposal. + [WpfFact] + public void SetDataObject_ValidMetafileStream_CopyTrue_RoundTripsAsMetafile() + { + byte[] bytes; + using (Metafile metafile = EmfTestData.CreateMetafile()) + { + bytes = EmfTestData.GetBytes(metafile); + } + + MemoryStream? sourceStream = EmfTestData.CreateNonExposableStream(bytes); + sourceStream.Position = Math.Min(7, sourceStream.Length); + + try + { + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, sourceStream, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + sourceStream.Dispose(); + sourceStream = null; + + Clipboard.ContainsData(DataFormats.EnhancedMetafile).Should().BeTrue(); + using Metafile result = GetTypedClipboardMetafile(DataFormats.EnhancedMetafile); + EmfTestData.AssertValid(result); + } + finally + { + Clipboard.Clear(); + } + } + finally + { + sourceStream?.Dispose(); + } + } + + // Verifies WMF data mislabeled as EnhancedMetafile is rejected without invalidating the caller's WMF object. + [WpfFact] + public void SetDataObject_WmfAsEnhancedMetafile_CopyTrue_DoesNotReturnEmf() + { + using MemoryStream stream = new(EmfTestData.CreateWmfBytes(), writable: false); + using Metafile source = new(stream); + source.GetMetafileHeader().IsWmf().Should().BeTrue(); + DataObject sourceDataObject = new(); + sourceDataObject.SetData(DataFormats.EnhancedMetafile, source, autoConvert: false); + + try + { + Clipboard.SetDataObject(sourceDataObject, copy: true); + + Clipboard.GetData(DataFormats.EnhancedMetafile).Should().BeNull(); + source.GetMetafileHeader().IsWmf().Should().BeTrue(); + } + finally + { + Clipboard.Clear(); + } + } + + private static byte[] ReadAllBytes(Stream? stream) + { + stream.Should().NotBeNull(); + Stream nonNullStream = stream!; + if (nonNullStream.CanSeek) + { + nonNullStream.Position = 0; + } + + using MemoryStream copy = new(); + nonNullStream.CopyTo(copy); + return copy.ToArray(); + } + + private static void EnsureWpfGraphicsLoaded() => + _ = s_wpfGraphics.Value; + + private static void ClearClipboardIfSta() + { + if (Thread.CurrentThread.GetApartmentState() == ApartmentState.STA) + { + RetryClipboardAccess( + () => + { + Clipboard.Clear(); + return true; + }); + } + } + + private static Metafile GetTypedClipboardMetafile(string format) + { + Metafile? result = null; + try + { + RetryClipboardAccess( + () => Clipboard.TryGetData(format, out result)).Should().BeTrue(); + result.Should().NotBeNull(); + return result!; + } + catch + { + result?.Dispose(); + throw; + } + } + + private static Metafile GetTypedDataObjectMetafile( + DataObject dataObject, + string format, + bool autoConvert) + { + Metafile? result = null; + try + { + RetryClipboardAccess( + () => dataObject.TryGetData( + format, + autoConvert, + out result)).Should().BeTrue(); + result.Should().NotBeNull(); + return result!; + } + catch + { + result?.Dispose(); + throw; + } + } + + private static T RetryClipboardAccess(Func operation) + { + const int ClipboardCannotOpen = unchecked((int)0x800401D0); + + for (int attemptsRemaining = 10; ; attemptsRemaining--) + { + try + { + return operation(); + } + catch (COMException exception) when (exception.HResult == ClipboardCannotOpen && attemptsRemaining > 1) + { + Thread.Sleep(100); + } + } + } + + private static bool OpenClipboardWithRetry() + { + for (int attemptsRemaining = 10; attemptsRemaining > 0; attemptsRemaining--) + { + if (OpenClipboard(0)) + { + return true; + } + + Thread.Sleep(100); + } + + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + private static string GetDataFormat(TextDataFormat format) => + format switch + { + TextDataFormat.Text => DataFormats.Text, + TextDataFormat.UnicodeText => DataFormats.UnicodeText, + TextDataFormat.Rtf => DataFormats.Rtf, + TextDataFormat.Html => DataFormats.Html, + TextDataFormat.CommaSeparatedValue => DataFormats.CommaSeparatedValue, + TextDataFormat.Xaml => DataFormats.Xaml, + _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(TextDataFormat)), + }; + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(nint newOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); } diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectTests.cs index 8456c13ded3..a16de8a966e 100644 --- a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectTests.cs +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/DataObjectTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Drawing.Imaging; using System.Windows.Media.Imaging; namespace System.Windows; @@ -294,6 +295,73 @@ public void SetData_StringObjectBool_EmptyKey_ThrowsArgumentException() Action act = () => data.SetData(string.Empty, testData, true); act.Should().Throw(); } + + // Verifies managed EMF mappings honor autoConvert while preserving the original Metafile instance. + [WpfTheory] + [InlineData("EnhancedMetafile", "System.Drawing.Imaging.Metafile")] + [InlineData("System.Drawing.Imaging.Metafile", "EnhancedMetafile")] + public void TryGetData_MetafileMappedFormat_RespectsAutoConvert( + string sourceFormat, + string mappedFormat) + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject dataObject = new(); + dataObject.SetData(sourceFormat, source, autoConvert: true); + + dataObject.GetDataPresent(sourceFormat, autoConvert: false).Should().BeTrue(); + dataObject.GetDataPresent(mappedFormat, autoConvert: false).Should().BeFalse(); + dataObject.GetDataPresent(mappedFormat, autoConvert: true).Should().BeTrue(); + + string[] nativeFormats = dataObject.GetFormats(autoConvert: false); + nativeFormats.Should().Contain(sourceFormat); + nativeFormats.Should().NotContain(mappedFormat); + + string[] convertedFormats = dataObject.GetFormats(autoConvert: true); + convertedFormats.Should().Contain(sourceFormat); + convertedFormats.Should().Contain(mappedFormat); + + dataObject.TryGetData( + sourceFormat, + autoConvert: false, + out Metafile? exactResult).Should().BeTrue(); + exactResult.Should().BeSameAs(source); + EmfTestData.AssertValid(exactResult!); + + dataObject.TryGetData( + mappedFormat, + autoConvert: false, + out Metafile? nonConvertedResult).Should().BeFalse(); + nonConvertedResult.Should().BeNull(); + + dataObject.TryGetData( + mappedFormat, + autoConvert: true, + out Metafile? convertedResult).Should().BeTrue(); + convertedResult.Should().BeSameAs(source); + EmfTestData.AssertValid(convertedResult!); + + dataObject.GetData(sourceFormat, autoConvert: false).Should().BeSameAs(source); + dataObject.GetData(mappedFormat, autoConvert: false).Should().BeNull(); + dataObject.GetData(mappedFormat, autoConvert: true).Should().BeSameAs(source); + EmfTestData.AssertValid(source); + } + + // Verifies typed requests reject an incompatible result type without corrupting the stored Metafile. + [WpfFact] + public void TryGetData_Metafile_WrongTypedRequestThrowsNotSupportedException() + { + using Metafile source = EmfTestData.CreateMetafile(); + DataObject dataObject = new(); + dataObject.SetData(DataFormats.EnhancedMetafile, source); + + Action action = () => dataObject.TryGetData( + DataFormats.EnhancedMetafile, + autoConvert: false, + out string? _); + + action.Should().Throw(); + EmfTestData.AssertValid(source); + } #endregion #region ContainsText Tests diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs new file mode 100644 index 00000000000..9abc8c701f9 --- /dev/null +++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationCore.Tests/System/Windows/EmfTestData.cs @@ -0,0 +1,213 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.ComponentModel; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace System.Windows; + +internal static class EmfTestData +{ + internal static Rectangle Frame { get; } = new(0, 0, 120, 80); + + // Records a deterministic rectangle into an in-memory EMF and returns an independently owned Metafile. + public static Metafile CreateMetafile() + { + using MemoryStream stream = new(); + using Bitmap referenceBitmap = new(1, 1); + using Graphics referenceGraphics = Graphics.FromImage(referenceBitmap); + + Metafile recording; + nint hdc = referenceGraphics.GetHdc(); + try + { + recording = new( + stream, + hdc, + Frame, + MetafileFrameUnit.Pixel, + EmfType.EmfOnly); + } + finally + { + referenceGraphics.ReleaseHdc(hdc); + } + + using (recording) + { + using Graphics graphics = Graphics.FromImage(recording); + nint recordingHdc = graphics.GetHdc(); + try + { + GdiRectangle(recordingHdc, 10, 10, 110, 62).Should().NotBe(0); + } + finally + { + graphics.ReleaseHdc(recordingHdc); + } + } + + using MemoryStream source = new(stream.ToArray(), writable: false); + using Metafile reader = new(source); + nint handle = reader.GetHenhmetafile(); + if (handle == 0) + { + throw new Win32Exception(); + } + + try + { + return new Metafile(handle, deleteEmf: true); + } + catch + { + DeleteEnhMetaFile(handle); + throw; + } + } + + // Copies the native EMF bits and releases the caller-owned handle returned by GetHenhmetafile. + public static byte[] GetBytes(Metafile metafile) + { + nint handle = metafile.GetHenhmetafile(); + if (handle == 0) + { + throw new Win32Exception(); + } + + try + { + uint size = GetEnhMetaFileBits(handle, 0, null); + if (size == 0) + { + throw new Win32Exception(); + } + + byte[] bytes = new byte[size]; + uint copied = GetEnhMetaFileBits(handle, size, bytes); + if (copied != size) + { + throw new Win32Exception(); + } + + return bytes; + } + finally + { + DeleteEnhMetaFile(handle).Should().BeTrue(); + } + } + + // Forces production code to use the non-public-buffer stream path rather than MemoryStream.TryGetBuffer. + public static MemoryStream CreateNonExposableStream(byte[] bytes) => + new(bytes, 0, bytes.Length, writable: false, publiclyVisible: false); + + // Converts the deterministic EMF to WMF bits and adds the placeable header required by GDI+ stream loading. + public static byte[] CreateWmfBytes() + { + using Metafile emf = CreateMetafile(); + nint emfHandle = emf.GetHenhmetafile(); + if (emfHandle == 0) + { + throw new Win32Exception(); + } + + using Bitmap referenceBitmap = new(1, 1); + using Graphics referenceGraphics = Graphics.FromImage(referenceBitmap); + nint referenceHdc = referenceGraphics.GetHdc(); + try + { + const int MmAnisotropic = 8; + uint size = GetWinMetaFileBits(emfHandle, 0, null, MmAnisotropic, referenceHdc); + if (size == 0) + { + throw new Win32Exception(); + } + + byte[] wmfBits = new byte[size]; + if (GetWinMetaFileBits(emfHandle, size, wmfBits, MmAnisotropic, referenceHdc) != size) + { + throw new Win32Exception(); + } + + return AddPlaceableWmfHeader(wmfBits); + } + + finally + { + referenceGraphics.ReleaseHdc(referenceHdc); + DeleteEnhMetaFile(emfHandle).Should().BeTrue(); + } + } + + // Prepends an Aldus placeable header and computes its XOR checksum over the first ten WORDs. + private static byte[] AddPlaceableWmfHeader(byte[] wmfBits) + { + const int HeaderSize = 22; + byte[] result = new byte[HeaderSize + wmfBits.Length]; + Span header = result.AsSpan(0, HeaderSize); + BinaryPrimitives.WriteUInt32LittleEndian(header, 0x9AC6CDD7); + BinaryPrimitives.WriteInt16LittleEndian(header[6..], (short)Frame.Left); + BinaryPrimitives.WriteInt16LittleEndian(header[8..], (short)Frame.Top); + BinaryPrimitives.WriteInt16LittleEndian(header[10..], (short)Frame.Right); + BinaryPrimitives.WriteInt16LittleEndian(header[12..], (short)Frame.Bottom); + BinaryPrimitives.WriteUInt16LittleEndian(header[14..], 1440); + + ushort checksum = 0; + for (int offset = 0; offset < 20; offset += sizeof(ushort)) + { + checksum ^= BinaryPrimitives.ReadUInt16LittleEndian(header[offset..]); + } + + BinaryPrimitives.WriteUInt16LittleEndian(header[20..], checksum); + wmfBits.CopyTo(result, HeaderSize); + return result; + } + + // Verifies a renderable EMF using DPI-independent bounds and meaningful drawing records. + public static void AssertValid(Metafile metafile) + { + MetafileHeader header = metafile.GetMetafileHeader(); + header.IsEmfOrEmfPlus().Should().BeTrue(); + header.Bounds.Width.Should().BePositive(); + header.Bounds.Height.Should().BePositive(); + ((double)header.Bounds.Width / header.Bounds.Height) + .Should().BeApproximately((double)Frame.Width / Frame.Height, precision: 0.01); + + List records = []; + using Bitmap target = new(Frame.Width, Frame.Height); + using Graphics graphics = Graphics.FromImage(target); + graphics.EnumerateMetafile( + metafile, + System.Drawing.Point.Empty, + (recordType, _, _, _, _) => + { + records.Add(recordType); + return true; + }); + + records.Should().Contain(EmfPlusRecordType.EmfHeader); + records.Should().Contain(EmfPlusRecordType.EmfRectangle); + records.Should().Contain(EmfPlusRecordType.EmfEof); + } + + [DllImport("gdi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DeleteEnhMetaFile(nint hEnhMetaFile); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern uint GetEnhMetaFileBits(nint hEnhMetaFile, uint bufferSize, [Out] byte[]? buffer); + + [DllImport("gdi32.dll", SetLastError = true)] + private static extern uint GetWinMetaFileBits( + nint hEnhMetaFile, + uint bufferSize, + [Out] byte[]? buffer, + int mapMode, + nint referenceHdc); + + [DllImport("gdi32.dll", EntryPoint = "Rectangle", SetLastError = true)] + private static extern int GdiRectangle(nint hdc, int left, int top, int right, int bottom); +}