文章目錄
- 技術選型
- 第一步:創建項目并安裝依賴庫
- 第二步:定義數據模型 (Model)
- 第三步:創建視圖模型 (ViewModel)
- 第四步:設計用戶界面 (View)
- 總結與解釋
- 后記
- 關于轉換器的錯誤
工作中需要整理一些PDF格式文件,程序員的存在就是為了讓大家可以“懶更高效地工作”,而AI的出現就可以讓程序更“懶高效地工作”,于是求助于很長(我指上下文)的Gemini,它幫助了我快速搭建項目,但也給我留下了坑(見本文“后記”部分),于是我把這個開發過程記錄了下來。
技術選型
- UI框架: WPF (.NET 6/7/8 或 .NET Framework 4.7.2+) - 用于構建現代化的Windows桌面應用。
- PDF處理: iText (替代了舊版的 iTextSharp 及 iText7) - 一個強大且流行的開源PDF處理庫。
- Excel導出: NPOI - 一個開源的.NET庫,可以讀寫Office文檔,無需安裝Microsoft Office。
- 設計模式: MVVM - 使UI和業務邏輯分離,提高代碼的可測試性和復用性。
第一步:創建項目并安裝依賴庫
-
打開 Visual Studio,創建一個新的 WPF 應用程序 項目(本文為.net 8.0項目)。
-
通過 NuGet 包管理器安裝以下必要的庫。在“解決方案資源管理器”中右鍵點擊你的項目,選擇“管理NuGet程序包”,然后搜索并安裝:
iText
NPOI
Microsoft.WindowsAPICodePack-Shell
(為了一個更好看的文件夾選擇對話框)
第二步:定義數據模型 (Model)
這是我們用來存儲每個PDF文件信息的類。
PdfFileInfo.cs
namespace PdfFileScanner
{public class PdfFileInfo{public string FileName { get; set; } = string.Empty;public int PageCount { get; set; }public string FileSize { get; set; } = string.Empty;}
}
第三步:創建視圖模型 (ViewModel)
ViewModel 是連接視圖和模型的橋梁,包含了所有的業務邏輯和UI狀態,在這里,我按照AI的提示創建了MainViewModel
類。
MainViewModel.cs
using iText.Kernel.Pdf;
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using Microsoft.Win32;
using Microsoft.WindowsAPICodePack.Dialogs; // For modern folder browsernamespace PdfFileScanner
{public class MainViewModel : INotifyPropertyChanged{// INotifyPropertyChanged 實現,用于通知UI屬性已更改public event PropertyChangedEventHandler? PropertyChanged;protected virtual void OnPropertyChanged(string propertyName){PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));}// 存儲PDF文件信息的集合,ObservableCollection能自動通知UI更新public ObservableCollection<PdfFileInfo> PdfFiles { get; } = new ObservableCollection<PdfFileInfo>();private string _statusText = "請選擇一個文件夾...";public string StatusText{get => _statusText;set { _statusText = value; OnPropertyChanged(nameof(StatusText)); }}private double _progressValue;public double ProgressValue{get => _progressValue;set { _progressValue = value; OnPropertyChanged(nameof(ProgressValue)); }}private bool _isBusy;public bool IsBusy{get => _isBusy;set{_isBusy = value;OnPropertyChanged(nameof(IsBusy));// 當IsBusy狀態改變時,通知命令重新評估其能否執行((RelayCommand)SelectFolderCommand).RaiseCanExecuteChanged();((RelayCommand)ExportToExcelCommand).RaiseCanExecuteChanged();}}// 命令綁定public ICommand SelectFolderCommand { get; }public ICommand ExportToExcelCommand { get; }public MainViewModel(){SelectFolderCommand = new RelayCommand(async () => await ProcessFolderAsync(), () => !IsBusy);ExportToExcelCommand = new RelayCommand(ExportToExcel, () => PdfFiles.Count > 0 && !IsBusy);}private async Task ProcessFolderAsync(){// 使用現代化的文件夾選擇對話框var dialog = new CommonOpenFileDialog{IsFolderPicker = true,Title = "請選擇包含PDF文件的文件夾"};if (dialog.ShowDialog() == CommonFileDialogResult.Ok){string selectedPath = dialog.FileName;IsBusy = true;StatusText = "正在準備處理...";PdfFiles.Clear();ProgressValue = 0;await Task.Run(() => // 在后臺線程執行耗時操作,避免UI卡死{var files = Directory.GetFiles(selectedPath, "*.pdf");int processedCount = 0;foreach (var file in files){processedCount++;var progressPercentage = (double)processedCount / files.Length * 100;// 更新UI元素必須在UI線程上執行Application.Current.Dispatcher.Invoke(() =>{StatusText = $"正在處理: {Path.GetFileName(file)} ({processedCount}/{files.Length})";ProgressValue = progressPercentage;});try{// 獲取文件信息var fileInfo = new FileInfo(file);int pageCount = 0;// 使用 iText7 讀取PDF頁數using (var pdfReader = new PdfReader(file)){using (var pdfDoc = new PdfDocument(pdfReader)){pageCount = pdfDoc.GetNumberOfPages();}}// 創建模型對象并添加到集合中var pdfData = new PdfFileInfo{FileName = fileInfo.Name,PageCount = pageCount,FileSize = $"{fileInfo.Length / 1024.0:F2} KB" // 格式化文件大小};Application.Current.Dispatcher.Invoke(() => PdfFiles.Add(pdfData));}catch (System.Exception ex){// 如果某個PDF文件損壞,記錄錯誤并繼續Application.Current.Dispatcher.Invoke(() =>{StatusText = $"處理文件 {Path.GetFileName(file)} 時出錯: {ex.Message}";});}}});StatusText = $"處理完成!共找到 {PdfFiles.Count} 個PDF文件。";IsBusy = false;}}private void ExportToExcel(){var saveFileDialog = new SaveFileDialog{Filter = "Excel 工作簿 (*.xlsx)|*.xlsx",FileName = $"PDF文件列表_{System.DateTime.Now:yyyyMMddHHmmss}.xlsx"};if (saveFileDialog.ShowDialog() == true){try{// 使用 NPOI 創建 ExcelIWorkbook workbook = new XSSFWorkbook();ISheet sheet = workbook.CreateSheet("PDF文件信息");// 創建表頭IRow headerRow = sheet.CreateRow(0);headerRow.CreateCell(0).SetCellValue("文件名");headerRow.CreateCell(1).SetCellValue("頁數");headerRow.CreateCell(2).SetCellValue("文件大小 (KB)");// 填充數據for (int i = 0; i < PdfFiles.Count; i++){IRow dataRow = sheet.CreateRow(i + 1);dataRow.CreateCell(0).SetCellValue(PdfFiles[i].FileName);dataRow.CreateCell(1).SetCellValue(PdfFiles[i].PageCount);dataRow.CreateCell(2).SetCellValue(PdfFiles[i].FileSize);}// 自動調整列寬sheet.AutoSizeColumn(0);sheet.AutoSizeColumn(1);sheet.AutoSizeColumn(2);// 寫入文件using (var fs = new FileStream(saveFileDialog.FileName, FileMode.Create, FileAccess.Write)){workbook.Write(fs);}MessageBox.Show("成功導出到Excel!", "導出成功", MessageBoxButton.OK, MessageBoxImage.Information);}catch (System.Exception ex){MessageBox.Show($"導出失敗: {ex.Message}", "錯誤", MessageBoxButton.OK, MessageBoxImage.Error);}}}}// 一個簡單的ICommand實現public class RelayCommand : ICommand{private readonly System.Action _execute;private readonly System.Func<bool>? _canExecute;public event System.EventHandler? CanExecuteChanged{add { CommandManager.RequerySuggested += value; }remove { CommandManager.RequerySuggested -= value; }}public RelayCommand(System.Action execute, System.Func<bool>? canExecute = null){_execute = execute;_canExecute = canExecute;}public bool CanExecute(object? parameter) => _canExecute == null || _canExecute();public void Execute(object? parameter) => _execute();public void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested();}
}
第四步:設計用戶界面 (View)
這是 MainWindow.xaml
文件,定義了程序窗口的布局和控件,并將它們綁定到 ViewModel。
MainWindow.xaml
<Window x:Class="PdfFileScanner.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:local="clr-namespace:PdfFileScanner"mc:Ignorable="d"Title="PDF文件掃描器" Height="600" Width="800" MinHeight="400" MinWidth="600"><!-- 設置窗口的數據上下文為ViewModel --><Window.DataContext><local:MainViewModel/></Window.DataContext><Grid Margin="10"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><!-- 頂部操作欄 --><StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10"><Button Content="選擇文件夾" Command="{Binding SelectFolderCommand}" Padding="15,5" FontSize="14" IsEnabled="{Binding !IsBusy}"/><Button Content="導出到Excel" Command="{Binding ExportToExcelCommand}" Margin="10,0,0,0" Padding="15,5" FontSize="14" IsEnabled="{Binding !IsBusy}"/></StackPanel><!-- 文件列表 --><DataGrid Grid.Row="1" ItemsSource="{Binding PdfFiles}" AutoGenerateColumns="False" CanUserAddRows="False" IsReadOnly="True" FontSize="14"><DataGrid.Columns><DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*"/><DataGridTextColumn Header="頁數" Binding="{Binding PageCount}" Width="Auto"/><DataGridTextColumn Header="文件大小" Binding="{Binding FileSize}" Width="Auto"/></DataGrid.Columns></DataGrid><!-- 底部狀態欄和進度條 --><Grid Grid.Row="2" Margin="0,10,0,0"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="200"/></Grid.ColumnDefinitions><TextBlock Grid.Column="0" Text="{Binding StatusText}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/><ProgressBar Grid.Column="1" Value="{Binding ProgressValue}" Maximum="100" Height="20"Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}"/></Grid></Grid>
</Window>
MainWindow.xaml.cs (代碼隱藏文件)
這里我們只需要確保 DataContext
被正確設置。上面的XAML已經通過 <local:MainViewModel/>
標簽完成了這一步,所以代碼隱藏文件非常干凈。
using System.Windows;namespace PdfFileScanner
{public partial class MainWindow : Window{public MainWindow(){InitializeComponent();// DataContext 在 XAML 中設置,這里無需代碼}}
}
總結與解釋
- 文件夾選擇: 點擊“選擇文件夾”按鈕,會觸發
SelectFolderCommand
。我們使用了Microsoft.WindowsAPICodePack-Shell
庫,它提供了一個比默認的FolderBrowserDialog
更現代、更友好的對話框。 - 后臺處理與進度更新:
- 核心的PDF文件處理邏輯被包裹在
Task.Run()
中,這會將其放到一個后臺線程上執行,防止UI線程(負責渲染窗口和響應用戶操作的線程)被阻塞而導致程序“未響應”。 - 在后臺線程中,我們不能直接修改UI控件(如
ProgressBar
或TextBlock
)或綁定到UI的集合(如ObservableCollection
)。因此,我們使用Application.Current.Dispatcher.Invoke()
將這些更新操作“派發”回UI線程執行,這是WPF中進行跨線程UI更新的標準做法。 IsBusy
屬性用來控制UI狀態。當IsBusy
為true
時,按鈕會被禁用,進度條會顯示。
- 核心的PDF文件處理邏輯被包裹在
- 信息提取:
- 文件名和大小: 使用
System.IO.FileInfo
類可以輕松獲取。 - PDF頁數: 使用
iText 7
庫。我們通過PdfReader
和PdfDocument
對象打開PDF文件,然后調用GetNumberOfPages()
方法。using
語句確保文件流被正確關閉和釋放。
- 文件名和大小: 使用
- 列表展示:
- WPF的
DataGrid
控件的ItemsSource
屬性被綁定到 ViewModel 中的ObservableCollection<PdfFileInfo>
集合。 ObservableCollection
的美妙之處在于,每當你向其中Add
或Remove
一個項時,它會自動通知綁定的DataGrid
更新,無需手動刷新。
- WPF的
- Excel導出:
- 點擊“導出到Excel”按鈕會觸發
ExportToExcelCommand
。 - 該命令首先會彈出一個標準的“文件保存”對話框,讓用戶選擇保存位置和文件名。
- 然后,它使用 NPOI 庫在內存中創建一個Excel工作簿 (
XSSFWorkbook
對應 .xlsx格式),創建工作表、表頭行,然后遍歷PdfFiles
集合,將每條數據寫入新的一行。 - 最后,將內存中的工作簿寫入到用戶選擇的文件流中。
- 點擊“導出到Excel”按鈕會觸發
這個方案完整地實現了你要求的所有功能,并且采用了現代C#和WPF的最佳實踐,代碼結構清晰,易于擴展和維護。
后記
關于轉換器的錯誤
Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}"
改代碼因沒有轉換器Converter而出錯,故需自定義一個轉換器:
添加轉換器類BooleanToVisibilityConverter
:
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;public class BooleanToVisibilityConverter : IValueConverter
{public object Convert(object value, Type targetType, object parameter, CultureInfo culture){if (value is bool booleanValue){if (booleanValue){return Visibility.Visible;}else{// Default to Collapsed, or Hidden based on 'parameter' or another propertyreturn Visibility.Collapsed;}}return Visibility.Visible; // Default if not a boolean}public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture){throw new NotImplementedException(); // Usually not needed for Visibility conversion}
}
然后在 MainWindow.xaml
中注冊這個轉換器:
<!-- 在這里添加資源定義 --><Window.Resources><BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/></Window.Resources>
修改后的MainWindow.xaml
文件如下:
<Window x:Class="PdfFileScanner.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:local="clr-namespace:PdfFileScanner"mc:Ignorable="d"Title="PDF文件掃描器" Height="600" Width="800" MinHeight="400" MinWidth="600"><!-- 設置窗口的數據上下文為ViewModel --><Window.DataContext><local:MainViewModel/></Window.DataContext><!-- 在這里添加資源定義 --><Window.Resources><BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/></Window.Resources><Grid Margin="10"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><!-- 頂部操作欄 --><StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10"><Button Content="選擇文件夾" Command="{Binding SelectFolderCommand}" Padding="15,5" FontSize="14" IsEnabled="{Binding !IsBusy}"/><Button Content="導出到Excel" Command="{Binding ExportToExcelCommand}" Margin="10,0,0,0" Padding="15,5" FontSize="14" IsEnabled="{Binding !IsBusy}"/></StackPanel><!-- 文件列表 --><DataGrid Grid.Row="1" ItemsSource="{Binding PdfFiles}" AutoGenerateColumns="False" CanUserAddRows="False" IsReadOnly="True" FontSize="14"><DataGrid.Columns><DataGridTextColumn Header="文件名" Binding="{Binding FileName}" Width="*"/><DataGridTextColumn Header="頁數" Binding="{Binding PageCount}" Width="Auto"/><DataGridTextColumn Header="文件大小" Binding="{Binding FileSize}" Width="Auto"/></DataGrid.Columns></DataGrid><!-- 底部狀態欄和進度條 --><Grid Grid.Row="2" Margin="0,10,0,0"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="200"/></Grid.ColumnDefinitions><TextBlock Grid.Column="0" Text="{Binding StatusText}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/><ProgressBar Grid.Column="1" Value="{Binding ProgressValue}" Maximum="100" Height="20"Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}"/></Grid></Grid>
</Window>
問題解決!
運行效果如下: