【茶包射手日記】PowerShell 剪貼簿格式化文字中文亂碼之謎
| | 3 | | 604 |
從網頁複製一段文字,在 Word/Outlook 貼上時有三個方式可選擇:「保留來源格式設定」、「合併格式設定」、「只保留文字」
若選擇保留來源格式,貼上的內容會維持原本的字型大小、顏色、粗體... 等樣式,這是大家很熟悉的日常操作。
各程式幾乎都有剪貼簿相關 API 允許你將特定文字複製到剪貼簿,方便在其他軟體貼上。最常見的應用是 API Key 又臭又長時,幾乎都有「複製到剪貼簿」按鈕,點一下便能在其他地方貼上,省下選取複製的工夫。
如果我們想在複製內容時保留格式,例如字型粗細顏色,甚至是表格等複雜格式,剪貼簿除了文字外也支援 HTML 格式,我們只需改提供 HTML 內容,便可輕鬆傳送格式化文字到剪貼簿。
以 PowerShell 的 Set-Clipboard 指令為例,它有個 -AsHtml 參數,不費吹灰之力便可傳送紅色文字到 Outlook。
Set-Clipboard -Value '<span style="color:red">Red Text</span>' -AsHtml
搞定收工?很遺憾,代誌嘸系憨人所想 A 哈尼啊甘單。
實測發現,這個 -AsHtml 只支援純英文,中文會變亂碼:
Set-Clipboard -Value '<span style="color:red">Red Text/紅色文字</span>' -AsHtml
第二個問題是,-AsHtml 參數 PowerShell 5.1 有,在 PowerShell 7+ 已經被移除了。
經過 N 個小時的研究跟實驗,我才搞清楚是怎麼一回事。
首先,提供 HTML 格式資料給剪貼簿時必須包含以下 Header 參考,而其中位置數字是以 Byte 為單位而非字元,數字會依編碼而不同(例如:中文字在 Unicode 是兩個 Byte,在 UTF-8 則是 3 個)。位置數字必須完全正確,才能正常貼上。
Version:0.9
StartHTML:0000000145
EndHTML:0000001151
StartFragment:0000000181
EndFragment:0000001115
PowerShell Set-Clipboard -AsHtml 的用意即在於它會自動加上 Header 並幫忙計算 StartHTML、StartFragment... 等位置,但看起來字串被強轉為 ANSI 編碼。
搞清楚背後原理也找到問題關鍵,要解決不是什麼難事。我先在 .NET 9 試做用 System.Windows.Forms 剪貼簿 API 自己加上 Header 用 UTF-8 算位置,成功後改寫成 PowerShell,總算能複製格式化文字了。(灑花)
Add-Type -AssemblyName System.Windows.Forms
function Format-HtmlForClipboard {
param([string]$html)
$headerFormat = "Version:0.9`r`nStartHTML:{0:D10}`r`nEndHTML:{1:D10}`r`nStartFragment:{2:D10}`r`nEndFragment:{3:D10}`r`n"
$htmlStart = "<html><body><!--StartFragment-->"
$htmlEnd = "<!--EndFragment--></body></html>"
$fragment = $htmlStart + $html + $htmlEnd
$offset = (10 - 7) * 4 # length diff between 0000000000 and {0:D10}
$stHtmlPos = $headerFormat.Length + $offset
$stFrgPos = $stHtmlPos + $htmlStart.Length
$edFrgPos = $stFrgPos + ([System.Text.Encoding]::UTF8.GetByteCount($html))
$edHtmlPos = $edFrgPos + $htmlEnd.Length - $htmlStart.Length
$formattedHeader = [string]::Format($headerFormat, $stHtmlPos, $edHtmlPos, $stFrgPos, $edFrgPos)
return $formattedHeader + $fragment
}
$htmlInput = '<div><span style="color:red">紅色文字</span> 來自 PowerShell</div>'
$formattedHtml = Format-HtmlForClipboard $htmlInput
$data = New-Object System.Windows.Forms.DataObject
# $data.SetData([System.Windows.Forms.DataFormats]::Text, "..視需要另提供純文字版....")
$data.SetData([System.Windows.Forms.DataFormats]::Html, $formattedHtml)
[System.Windows.Forms.Clipboard]::SetDataObject($data, $true)
Write-Host "已複製 HTML 到剪貼簿"
很不幸地,我發現上述程式只適用 PowerShell 7+,在 PowerShell 5.1 仍中文仍會變 ��的問題(默默把花撿起來)。
再深入研究,問題出在 PowerShell 5.1 跟 7 用的 System.Windows.Forms.dll 不是同一個,分別是 C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Windows.Forms/v4.0_4.0.0.0__b77a5c561934e089/System.Windows.Forms.dll
跟 C:/Program Files/PowerShell/7/System.Windows.Forms.dll
。查到有人提到這是 .NET 4 Clipboard 的 Bug! 依剪貼簿規格 HTML 內容的 Encoding 是 UTF-8,但 .NET 4 錯用 Windows-1252 編碼處理。
我用以下 .NET 4.8 程式實測,一樣是用 C:/WINDOWS/Microsoft.Net/assembly/GAC_MSIL/System.Windows.Forms/v4.0_4.0.0.0__b77a5c561934e089/System.Windows.Forms.dll
,想重現問題,登楞! 同樣的程式在 .NET 是可以正常處理中文的!
[STAThread]
static void Main()
{
var html = FormatHtmlForClipboard(@"<i style=""color:red"">中文測試 1234</i>");
var data = new DataObject();
data.SetData(DataFormats.Html, html);
Clipboard.SetDataObject(data, true);
Console.WriteLine(typeof(DataObject).Assembly.CodeBase);
var atLeast45 = typeof(DataObject).Assembly.GetType("System.Windows.Forms.WindowsFormsUtils")
.GetProperty("TargetsAtLeast_v4_5", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
.GetValue(null);
Console.WriteLine($"TargetsAtLeast_v4_5: {atLeast45}");
Console.ReadLine();
}
同一個 .NET 4 API,在 .NET 4.8 Console 跟 PowerShell 5.1 下表現不同,這還是頭一回遇到。追進原始碼,我發現 System.Windows.Forms.dll 在處理 HTML 格式時,依據一個內部屬性 System.Windows.Forms.WindowsFormsUtils.TargetsAtLeast_v4_5
採不同的處理,而註解也大方承認 TargetsAtLeast_v4_5 == false
這段邏輯會把 ANSI 當 UTF-8,但因為網路上有很多 Workaround 了,所以還是保留 NetFX 4.0 的錯誤行為。註解裡的這段自白,對於造成亂碼一事坦承不諱,好一個坦蕩蕩的 Bug... 噗~
else if (format.Equals(DataFormats.Html)) {
if (WindowsFormsUtils.TargetsAtLeast_v4_5) {
data = ReadHtmlFromHandle(hglobal);
}
else {
// This will return UTF-8 strings as an array of ANSI characters, which makes it the wrong behavior.
// Since there are enough samples online how to workaround that, we will continue to return the
// incorrect value to applications targeting netfx 4.0
// DevDiv2 bug 862524
data = ReadStringFromHandle(hglobal, false);
}
}
至於為什麼 .NET 4.8 Console 不會出錯?因為在 .NET 4.8 Console 專案中 TargetsAtLeast_v4_5
的值是 True。而在 PowerShell 5.1 TargetsAtLeast_v4_5
的結果是 False。
$utilsType = [System.Windows.Forms.DataObject].Assembly.GetType("System.Windows.Forms.WindowsFormsUtils")
$targetsAtLeast_v4_5 = $utilsType.GetProperty('TargetsAtLeast_v4_5', [Reflection.BindingFlags]::NonPublic -bor [Reflection.BindingFlags]::Static).GetValue($null)
Write-Host "TargetsAtLeast_v4_5: $targetsAtLeast_v4_5"
一切都有了合理解釋。
至於解法?考量 PowerShell 5.1 遲早要步入歷史,此刻已不值得多花工夫,決定避開。
Using PowerShell’s Set-Clipboard, you can copy HTML formatted text, but it has limitations with non-ASCII characters and is not available in newer PowerShell versions. This script, utilizing System.Windows.Forms, overcomes these issues by properly formatting the clipboard HTML content.
Comments
# by 小黑
黑大真的很頂
# by 小黃
PowerShell 5.1解法 (win10以上) Add-Type -AssemblyName System.Runtime.WindowsRuntime [Windows.ApplicationModel.DataTransfer.HtmlFormatHelper , Windows.ApplicationModel.DataTransfer , ContentType=WindowsRuntime] > $null $s = '<span style="color:red">Red Text/紅色文字</span>' $s = [Windows.ApplicationModel.DataTransfer.HtmlFormatHelper]::CreateHtmlFormat($s) $data = [Windows.ApplicationModel.DataTransfer.DataPackage]::new() $data.SetHtmlFormat($s) [Windows.ApplicationModel.DataTransfer.Clipboard]::SetContent($data)
# by Jeffrey
to 小黃,感謝分享。利用 Windows.ApplicationModel.DataTransfer.DataPackage 突圍,妙招!