使用 WebView2 封裝一個生成 PDF 的 WPF 控制元件
最近在遷移專案到 .net6,發現專案中用的 PDF 庫不支援 .net6,於是想著換一個庫。結果找了一大圈,發現不是版本不支援,就是收費。
嗐!還能咋辦,只能自己搞一個 PDF 生成控制元件咯。
環境準備 WPF + WebView2 + Vue
WebView2
- WebView2.CoreWebView2.PrintToPdfAsync 可以將 html 檔案生成 pdf。
- CEF 也有類似的 API,Evergreen WebView2 會自動更新,而且不需要將庫打包到程式中,所以就用它了。
- WebView2 需要先安裝到本機,下載連結。
Vue
- 直接操作 Dom 不夠方便,Vue 用法跟 WPF 的繫結方式又很相似,使用 vue 來定義 pdf 的 Html 的模板,可以讓不會 h5 的同事也能輕鬆寫模板檔案,所以這裡用 Vue 來操作 Dom 和資料繫結。
Prism
- WPF 專案常用的框架,我這裡用來註冊預覽 PDF 的彈窗,以及給彈窗傳參。
以列印一個表格為例
1. 定義要生成 PDF 的表格
// BuyBookView.xaml
<DataGrid
Grid.Row="1"
Margin="24,0"
AutoGenerateColumns="False"
FontSize="16"
IsReadOnly="True"
ItemsSource="{Binding Books}"
TextBlock.TextAlignment="Center">
<DataGrid.Columns>
<DataGridTextColumn
Width="*"
Binding="{Binding Title}"
Header="書名"
HeaderStyle="{StaticResource CenterGridHeaderStyle}" />
<DataGridTextColumn
Width="100"
Binding="{Binding Author}"
Header="作者"
HeaderStyle="{StaticResource CenterGridHeaderStyle}" />
<DataGridTextColumn
Width="100"
Binding="{Binding Price}"
Header="價格"
HeaderStyle="{StaticResource CenterGridHeaderStyle}" />
</DataGrid.Columns>
</DataGrid>
// BuyBookViewModel
public BuyBookViewModel(IDialogService dialogService)
{
Title = "鴨霸的購書目錄";
Books = new List<Book>
{
new()
{
Title = "JavaScript權威指南 原書第7版",
Author = "巨佬1",
Price = 90.3
},
new()
{
Title = "深入淺出node.js",
Author = "巨佬2",
Price = 57.8
},
new()
{
Title = "編碼:隱匿在計算機軟硬體背後的語言",
Author = "巨佬3",
Price = 89.00
}
};
}
2. 定義預覽 PDF 的彈窗
- 在 xaml 中引入 WebView2
// PrintPdfView.xml
...
xmlns:wpf="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
...
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid>
<wpf:WebView2 x:Name="webView2" />
</Grid>
<Grid Grid.Row="1">
<Button
x:Name="save"
HorizontalAlignment="Right"
Content="儲存" />
</Grid>
</Grid>
- 在 viewmodel 中定義彈窗接收的引數以及彈窗的屬性
// PrintPdfViewModel.cs
public class PrintPdfViewModel : BindableBase, IDialogAware
{
private string _template;
/// <summary>
/// PDF 的 html 模板
/// </summary>
public string Template
{
get => _template;
set => SetProperty(ref _template, value);
}
private ExpandoObject _data;
/// <summary>
/// 傳遞給 pdf 的資料
/// </summary>
public ExpandoObject Data
{
get => _data;
set => SetProperty(ref _data, value);
}
public void OnDialogOpened(IDialogParameters parameters)
{
// 彈窗接收 template 和 data 兩個引數
parameters.TryGetValue("template", out _template);
parameters.TryGetValue("data", out _data);
}
public string Title => "預覽 PDF";
}
3. 定義 WebView2 生成 PDF 的邏輯和 pdf 的模板檔案
- 使用 vue 來定義 pdf 模板的邏輯,和呼叫 WebView2.CoreWebView2.PrintToPdfAsync 來生成 PDF。
- 因為客戶端經常執行在內網或無網環境,所以這裡就不用 cdn 引入 vuejs,而是直接將 vuejs 嵌入到客戶端的資原始檔中。
- 呼叫 WebView2.CoreWebView2.PostWebMessageAsJson 從 WPF 向 WebView2 傳送資料。
// PrintPdfViewModel.xaml.cs
/// <summary>
/// 配置 WebView2,載入 vuejs,載入 pdf 模板,傳遞資料到 html 中
/// </summary>
/// <returns></returns>
private async Task Load()
{
await webView2.EnsureCoreWebView2Async();
webView2.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; // 禁止右鍵選單
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "PrintPdf.Views.vue.global.js";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
var vue = await reader.ReadToEndAsync();
await webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(vue); // 載入 vuejs
}
var vm = (PrintPdfViewModel)DataContext;
webView2.CoreWebView2.NavigateToString(vm.Template); // 載入 pdf 模板
webView2.CoreWebView2.NavigationCompleted += (sender, args) =>
{
var json = JsonSerializer.Serialize(vm.Data);
webView2.CoreWebView2.PostWebMessageAsJson(json); // 將資料傳遞到 html 中
};
}
- 點選儲存時,選擇路徑並生成 PDF 檔案。
// PrintPdfViewModel.xaml.cs
save.Click += async (sender, args) =>
{
var saveFileDialog = new SaveFileDialog
{
Filter = "txt files (*.pdf)|*.pdf",
RestoreDirectory = true,
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
FileName = $"test.pdf"
};
var result = saveFileDialog.ShowDialog();
if (result != true)
return;
var printSetting = webView2.CoreWebView2.Environment.CreatePrintSettings();
printSetting.ShouldPrintBackgrounds = true;
var saveResult = await webView2.CoreWebView2.PrintToPdfAsync($"{saveFileDialog.FileName}", printSetting);
};
- 定義 pdf 的列印模板,並且使用 Vue 來實現繫結功能,呼叫 webview.addEventListener 來監聽 WPF 傳遞給 WebView2 的資料。
<html lang="en">
<head>
...
</head>
<body>
<div id="app">
<div id="header">
<h3>
{{title}}
</h3>
</div>
<div id="content">
<table>
<thead>
<tr>
<th>序號</th>
<th>書名</th>
<th>作者</th>
<th>價格</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, i) in books">
<th>{{i+1}}</th>
<td>{{item.Title}}</td>
<td>{{item.Author}}</td>
<td>{{item.Price}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
<script>
// 呼叫 webview.addEventListener 來監聽 WPF 傳遞給 WebView2 的資料。
window.chrome.webview.addEventListener('message', event => generate(event.data));
// 完成資料繫結
function generate(data) {
const app = Vue.createApp({
data() {
return {title, books} = data;
},
});
app.mount('#app');
}
</script>
</html>
- 在 WPF 客戶端點選生成 PDF 時,開啟 PDF 預覽視窗,並且傳遞模板和資料給 WebView2
// BuyBookView.xaml
<Button Command="{Binding ShowPrintViewCommand}" Content="預覽 PDF1 " />
// BuyBookViewModel
ShowPrintViewCommand = new DelegateCommand(() =>
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"PrintPdf.ViewModels.test_print.html";
using var stream = assembly.GetManifestResourceStream(resourceName); // 載入模板
if (stream == null) return;
using var reader = new StreamReader(stream);
var t = reader.ReadToEnd();
dynamic d = new ExpandoObject(); // 轉換資料
d.title = Title;
d.books = Books;
var p = new DialogParameters
{
{"template", t},
{"data", d}
};
dialogService.ShowDialog(nameof(PrintPdfView), p, null);
});
4. 效果
5. 優化一下
現在功能已經差不多了,但是 html 模板需要寫的 js 太多,而且這是一個 WPF 控制元件,所以應該封裝一下,最好用起來跟 wpf 一樣才更好。
既然都用 vue 了,那就用 vue 封裝一下元件。
- vue 封裝一下表格控制元件,並且暴露出屬性 itemSource 和 columns
// controls.js
const DataGrid = {
props: ["itemsSource", "columns"],
template: `
<table style="width: 100%; border-collapse: collapse; border: 1px solid black; ">
<thead>
<tr>
<th v-for="column in columns" style="border: 1px solid black; background-color: lightblue; height: 40px;">
{{column.Header}}
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in itemsSource">
<td v-for="column in columns" style="text-align: center; vertical-align: middle; border: 1px solid black; height: 32px;">
{{item[column.Binding]}}
</td>
</tr>
</tbody>
</table>
`
}
const DocumentHeader = {
props: ["title"],
template: `
<div style="width: 70%; height: 100px; margin: 0 auto; display: flex; align-items: center; justify-content: center;">
<h2>{{title}}</h2>
</div>
`
};
- 將 controls.js 注入到 WebView2 中
var assembly = Assembly.GetExecutingAssembly();
var controlsFile = "PrintPdf.Views.controls.js";
using var controlsStream = assembly.GetManifestResourceStream(controlsFile);
using var controlsReader = new StreamReader(controlsStream);
var controls = await controlsReader.ReadToEndAsync();
await webView2.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(controls);
- 現在 html 模板中的 data-grid 元件就跟 WPF 的 DataGrid 控制元件很相似了
<html lang="en">
<head>
...
</head>
<body>
<div id="app">
<document-header :title="title"></document-header>
<data-grid :items-source="books" :columns="columns"></data-grid>
</div>
</body>
<script>
window.chrome.webview.addEventListener('message', event => generate(event.data));
function generate(data) {
Vue.createApp({
data() {
return {
title,columns,books
} = data;
},
components: {
DataGrid,
DocumentHeader
}
}).mount('#app');
}
</script>
</html>