> 技术文档 > C# WPF 实现读取文件夹中的PDF并显示其页数

C# WPF 实现读取文件夹中的PDF并显示其页数


文章目录

      • 技术选型
      • 第一步:创建项目并安装依赖库
      • 第二步:定义数据模型 (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和业务逻辑分离,提高代码的可测试性和复用性。

第一步:创建项目并安装依赖库

  1. 打开 Visual Studio,创建一个新的 WPF 应用程序 项目(本文为.net 8.0项目)。
    C# WPF 实现读取文件夹中的PDF并显示其页数

  2. 通过 NuGet 包管理器安装以下必要的库。在“解决方案资源管理器”中右键点击你的项目,选择“管理NuGet程序包”,然后搜索并安装:

    • iText
    • NPOI
    • Microsoft.WindowsAPICodePack-Shell (为了一个更好看的文件夹选择对话框)
      C# WPF 实现读取文件夹中的PDF并显示其页数

第二步:定义数据模型 (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 创建 Excel  IWorkbook 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\">  <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已经通过 标签完成了这一步,所以代码隐藏文件非常干净。

using System.Windows;namespace PdfFileScanner{ public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); // DataContext 在 XAML 中设置,这里无需代码 } }}

总结与解释

  1. 文件夹选择: 点击“选择文件夹”按钮,会触发 SelectFolderCommand。我们使用了 Microsoft.WindowsAPICodePack-Shell 库,它提供了一个比默认的 FolderBrowserDialog 更现代、更友好的对话框。
  2. 后台处理与进度更新:
    • 核心的PDF文件处理逻辑被包裹在 Task.Run() 中,这会将其放到一个后台线程上执行,防止UI线程(负责渲染窗口和响应用户操作的线程)被阻塞而导致程序“未响应”。
    • 在后台线程中,我们不能直接修改UI控件(如 ProgressBarTextBlock)或绑定到UI的集合(如 ObservableCollection)。因此,我们使用 Application.Current.Dispatcher.Invoke() 将这些更新操作“派发”回UI线程执行,这是WPF中进行跨线程UI更新的标准做法。
    • IsBusy 属性用来控制UI状态。当 IsBusytrue 时,按钮会被禁用,进度条会显示。
  3. 信息提取:
    • 文件名和大小: 使用 System.IO.FileInfo 类可以轻松获取。
    • PDF页数: 使用 iText 7 库。我们通过 PdfReaderPdfDocument 对象打开PDF文件,然后调用 GetNumberOfPages() 方法。using 语句确保文件流被正确关闭和释放。
  4. 列表展示:
    • WPF的 DataGrid 控件的 ItemsSource 属性被绑定到 ViewModel 中的 ObservableCollection 集合。
    • ObservableCollection 的美妙之处在于,每当你向其中 AddRemove 一个项时,它会自动通知绑定的 DataGrid 更新,无需手动刷新。
  5. Excel导出:
    • 点击“导出到Excel”按钮会触发 ExportToExcelCommand
    • 该命令首先会弹出一个标准的“文件保存”对话框,让用户选择保存位置和文件名。
    • 然后,它使用 NPOI 库在内存中创建一个Excel工作簿 (XSSFWorkbook 对应 .xlsx格式),创建工作表、表头行,然后遍历 PdfFiles 集合,将每条数据写入新的一行。
    • 最后,将内存中的工作簿写入到用户选择的文件流中。

这个方案完整地实现了你要求的所有功能,并且采用了现代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 property return 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\">  <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>

问题解决!

运行效果如下:

C# WPF 实现读取文件夹中的PDF并显示其页数

C# WPF 实现读取文件夹中的PDF并显示其页数