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

文章目录

工作中需要整理一些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项目)。

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

    • iText
    • NPOI
    • Microsoft.WindowsAPICodePack-Shell (为了一个更好看的文件夹选择对话框)

第二步:定义数据模型 (Model)

这是我们用来存储每个PDF文件信息的类。

PdfFileInfo.cs

csharp 复制代码
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

csharp 复制代码
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 browser

namespace 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

xml 复制代码
<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/> 标签完成了这一步,所以代码隐藏文件非常干净。

csharp 复制代码
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<PdfFileInfo> 集合。
    • ObservableCollection 的美妙之处在于,每当你向其中 AddRemove 一个项时,它会自动通知绑定的 DataGrid 更新,无需手动刷新。
  5. Excel导出 :
    • 点击"导出到Excel"按钮会触发 ExportToExcelCommand
    • 该命令首先会弹出一个标准的"文件保存"对话框,让用户选择保存位置和文件名。
    • 然后,它使用 NPOI 库在内存中创建一个Excel工作簿 (XSSFWorkbook 对应 .xlsx格式),创建工作表、表头行,然后遍历 PdfFiles 集合,将每条数据写入新的一行。
    • 最后,将内存中的工作簿写入到用户选择的文件流中。

这个方案完整地实现了你要求的所有功能,并且采用了现代C#和WPF的最佳实践,代码结构清晰,易于扩展和维护。

后记

关于转换器的错误

Visibility="{Binding IsBusy, Converter={StaticResource BooleanToVisibilityConverter}}" 改代码因没有转换器Converter而出错,故需自定义一个转换器:

添加转换器类BooleanToVisibilityConverter

csharp 复制代码
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 中注册这个转换器:

xml 复制代码
    <!-- 在这里添加资源定义 -->
    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
    </Window.Resources>

修改后的MainWindow.xaml文件如下:

xml 复制代码
<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>

问题解决!

运行效果如下:

相关推荐
孟婆来包棒棒糖~4 小时前
SpringCloude快速入门
分布式·后端·spring cloud·微服务·wpf
mixiumixiu8 小时前
免费 PDF 转 Word 工具:无水印 / 支持批量转换,本地运行更安全【附工具下载】
pdf
POLOAPI8 小时前
从模型到生产:AI 大模型落地工程与效率优化实践
人工智能·gpt·gemini
wangnaisheng10 小时前
【WPF】NumericUpDown的用法
wpf
时光追逐者11 小时前
C#/.NET/.NET Core技术前沿周刊 | 第 48 期(2025年7.21-7.27)
c#·.net·.netcore·.net core
蓝点lilac11 小时前
C# 调用邮箱应用发送带附件的邮件
c#·.net
工程师00712 小时前
C#多线程,同步与异步详解
开发语言·c#·多线程·同步·异步编程
小乖兽技术14 小时前
在 .NET 中使用 Base64 时容易踩的坑总结
开发语言·c#·.net
全栈技术负责人18 小时前
前端静态资源优化
前端·性能优化·pdf