從網頁複製一段文字,在 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.dllC:/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 突圍,妙招!

Post a comment