PDF 文件除了包含文本、图片和表格,还可以嵌入 Excel、Word、CSV 等其他格式的文件。例如,一份项目报告可能附带财务数据表,或者在某张图表旁附加原始数据文件。
如果需要进一步处理这些附件,可以通过 C# 直接将它们从 PDF 中提取出来,无需逐个打开和保存。
不过,PDF 中的附件主要分为两种:文档级附件和页面注释附件。它们的存储位置不同,提取方式也有所区别。
本文将介绍如何使用 C# 提取这两类 PDF 附件,并将文件分类保存到本地目录。同时,还会处理附件重名的问题,避免提取过程中覆盖已有文件。
一、准备工作
首先,在 C# 项目中安装必要的 PDF 文档处理库。
打开 Visual Studio,依次选择“工具”→“NuGet 包管理器”→“程序包管理器控制台”,执行:
Install-Package Spire.PDF
也可以通过 .NET CLI 安装:
dotnet add package Spire.PDF
本文使用 Project_Review.pdf 作为示例文件。运行代码前,需要准备一份包含附件的 PDF,并将其放在程序的工作目录中。
二、PDF 附件的两种类型
PDF 附件并不一定都存放在同一个位置。根据添加方式的不同,主要可以分为以下两类。
1. 文档级附件(Document-Level Attachments)
这类附件与整个 PDF 文档关联,不依附于某个具体页面,通常可以在 PDF 阅读器的“附件”面板中找到。
例如,一份项目报告可能包含以下附件:
-
Financial_Summary.xlsx:财务数据表 -
Meeting_Agenda.docx:会议议程
在 C# 中,可以通过 PdfDocument.Attachments 属性读取这些文件。
2. 页面注释附件(Attachment Annotations)
这类附件附加在 PDF 页面的某个位置,通常以回形针等图标显示。
例如,在报告第 2 页的图表旁附加一个 Chart_Data.csv 文件,供读者查看图表对应的原始数据。
页面注释附件需要通过页面的 Annotations 集合获取,并识别其中的 PdfAttachmentAnnotationWidget 对象。
因此,如果只读取 PdfDocument.Attachments ,就可能遗漏附加在 PDF 页面上的文件。
下面分别介绍这两种附件的提取方法。
三、使用 C# 提取 PDF 文档级附件
文档级附件可以通过 PdfDocument.Attachments 集合直接获取。
首先,加载需要处理的 PDF 文件:
PdfDocument pdf = new PdfDocument();
pdf.LoadFromFile("Project_Review.pdf");
然后,创建用于保存附件的文件夹:
string outputFolder = "DocumentAttachments";
Directory.CreateDirectory(outputFolder);
通过 Attachments 属性获取附件集合,再逐个读取其中的文件名和二进制数据:
PdfAttachmentCollection attachments = pdf.Attachments;
for (int i = 0; i < attachments.Count; i++)
{
PdfAttachment attachment = attachments[i];
string outputPath = Path.Combine(
outputFolder,
Path.GetFileName(
attachment.FileName.Replace('\', '/')
)
);
File.WriteAllBytes(outputPath, attachment.Data);
}
这里主要使用了以下几个属性和方法:
-
pdf.Attachments:获取 PDF 中的文档级附件集合。 -
attachment.FileName:获取附件的文件名。 -
attachment.Data:读取附件的二进制内容。 -
File.WriteAllBytes():将附件数据写入本地文件。
例如,如果 PDF 中包含 Financial_Summary.xlsx,程序会将其提取到 DocumentAttachments 文件夹中。
附件提取不会改变文件格式,而是直接保存 PDF 中嵌入的文件数据。
需要注意,上面的代码主要用于演示附件读取过程。如果多个附件具有相同的文件名,直接使用 File.WriteAllBytes() 可能覆盖之前保存的文件。后面的完整示例会解决这个问题。
四、使用 C# 提取 PDF 页面注释附件
页面注释附件需要从 PDF 各页面的注释集合中查找。
由于附件可能分布在不同页面,因此需要遍历整个 PDF,而不能只检查第一页。
1. 遍历页面注释
通过 pdf.Pages 访问所有页面,并使用 Annotations 属性获取每页的注释集合:
for (int pageIndex = 0; pageIndex < pdf.Pages.Count; pageIndex++)
{
PdfAnnotationCollection annotations =
pdf.Pages[pageIndex].Annotations;
// 遍历当前页面的注释
}
一个 PDF 页面可能包含文本批注、高亮、链接和附件等不同类型的注释,因此还需要判断注释对象是否属于附件类型。
2. 识别并提取附件
使用 is 判断注释是否为 PdfAttachmentAnnotationWidget,如果是,就读取其文件名和二进制数据。
for (int pageIndex = 0; pageIndex < pdf.Pages.Count; pageIndex++)
{
PdfAnnotationCollection annotations =
pdf.Pages[pageIndex].Annotations;
for (int i = 0; i < annotations.Count; i++)
{
if (annotations[i] is PdfAttachmentAnnotationWidget attachment)
{
string fileName = Path.GetFileName(
attachment.FileName.Replace('\', '/')
);
string outputPath = Path.Combine(
"AnnotationAttachments",
$"page_{pageIndex + 1}_{fileName}"
);
File.WriteAllBytes(outputPath, attachment.Data);
}
}
}
这段代码会检查每个页面中的注释,只提取符合附件类型的对象。
其中,attachment.FileName 用于获取文件名,attachment.Data 用于读取附件内容。
为了方便区分附件来源,代码还在文件名前添加了页码。
例如,PDF 第 2 页附加的 Chart_Data.csv 会保存为:
page_2_Chart_Data.csv
这样即使不同页面包含名称相同的附件,也更容易区分它们的来源。
上述代码同样是基础示例,实际使用时应提前创建输出文件夹,并处理可能出现的文件名冲突。
五、完整代码:一次性提取 PDF 中的所有附件
在实际项目中,PDF 可能同时包含文档级附件和页面注释附件。
下面的完整示例会将两种附件分别保存到不同文件夹,并实现以下功能:
- 提取全部文档级附件。
- 遍历所有页面,提取页面注释附件。
- 在页面附件的文件名前添加页码。
- 遇到同名文件时自动添加数字后缀,避免覆盖已有文件。
using System;
using System.IO;
using Spire.Pdf;
using Spire.Pdf.Attachments;
using Spire.Pdf.Annotations;
class Program
{
static void Main()
{
string inputFile = "Project_Review.pdf";
string outputRoot = "ExtractedFiles";
string documentFolder = Path.Combine(
outputRoot, "DocumentAttachments");
string annotationFolder = Path.Combine(
outputRoot, "AnnotationAttachments");
// 创建附件输出目录
Directory.CreateDirectory(documentFolder);
Directory.CreateDirectory(annotationFolder);
PdfDocument pdf = new PdfDocument();
try
{
// 加载 PDF
pdf.LoadFromFile(inputFile);
int extractedCount = 0;
// 提取文档级附件
PdfAttachmentCollection attachments = pdf.Attachments;
for (int i = 0; i < attachments.Count; i++)
{
PdfAttachment attachment = attachments[i];
string outputPath = GetUniqueFilePath(
documentFolder, attachment.FileName);
File.WriteAllBytes(
outputPath, attachment.Data);
Console.WriteLine(
$"文档附件:{outputPath}");
extractedCount++;
}
// 遍历所有页面,提取注释附件
for (int pageIndex = 0;
pageIndex < pdf.Pages.Count;
pageIndex++)
{
PdfAnnotationCollection annotations =
pdf.Pages[pageIndex].Annotations;
for (int i = 0; i < annotations.Count; i++)
{
if (annotations[i] is PdfAttachmentAnnotationWidget attachment)
{
string outputPath = GetUniqueFilePath(
annotationFolder,
attachment.FileName,
$"page_{pageIndex + 1}_"
);
File.WriteAllBytes(
outputPath, attachment.Data);
Console.WriteLine(
$"第 {pageIndex + 1} 页附件:{outputPath}");
extractedCount++;
}
}
}
Console.WriteLine(
$"提取完成,共保存 {extractedCount} 个附件。");
}
finally
{
// 关闭 PDF 文档
pdf.Close();
}
}
// 生成不与现有文件冲突的输出路径
static string GetUniqueFilePath(
string folder,
string originalName,
string prefix = "")
{
// 去除文件名中可能包含的目录路径
string fileName = Path.GetFileName(
(originalName ?? "").Replace('\', '/'));
if (string.IsNullOrWhiteSpace(fileName) ||
fileName == "." || fileName == "..")
{
fileName = "attachment.bin";
}
// 替换文件名中的非法字符
foreach (char invalidChar in Path.GetInvalidFileNameChars())
{
fileName = fileName.Replace(invalidChar, '_');
}
fileName = prefix + fileName;
string nameWithoutExtension =
Path.GetFileNameWithoutExtension(fileName);
string extension = Path.GetExtension(fileName);
string outputPath = Path.Combine(folder, fileName);
// 文件名冲突时添加数字后缀
int counter = 2;
while (File.Exists(outputPath) ||
Directory.Exists(outputPath))
{
string newName =
$"{nameWithoutExtension}_{counter}{extension}";
outputPath = Path.Combine(folder, newName);
counter++;
}
return outputPath;
}
}
运行结果
假设 Project_Review.pdf 包含两个文档级附件,以及一个位于第 2 页的注释附件。
程序运行后,生成的目录结构如下:
ExtractedFiles/
DocumentAttachments/
Financial_Summary.xlsx
Meeting_Agenda.docx
AnnotationAttachments/
page_2_Chart_Data.csv
程序会在控制台输出每个附件的保存路径,以及最终提取的文件数量。
其中,GetUniqueFilePath() 方法用于处理输出文件名。如果目录中已经存在 Financial_Summary.xlsx,程序会尝试使用 Financial_Summary_2.xlsx 等名称,避免直接覆盖原文件。
对于页面注释附件,文件名前的 page_2_ 表示附件来自 PDF 第 2 页。
这类处理尤其适合附件数量较多,或者需要统一管理附件的场景。
六、只提取 PDF 中指定格式的附件
有时并不需要提取全部文件,而是只关心某些格式。
例如,处理财务报告时,可能只需要提取其中的 Excel 工作簿,忽略其他类型的附件。
可以在保存附件之前检查文件扩展名:
for (int i = 0; i < attachments.Count; i++)
{
PdfAttachment attachment = attachments[i];
string extension = Path.GetExtension(
attachment.FileName ?? "").ToLowerInvariant();
if (extension == ".xlsx" || extension == ".xls")
{
string outputPath = GetUniqueFilePath(
documentFolder, attachment.FileName);
File.WriteAllBytes(
outputPath, attachment.Data);
Console.WriteLine($"已提取:{outputPath}");
}
}
这段代码只处理 .xlsx 和 .xls 文件,其他格式会被跳过。
如果需要提取 CSV、Word 等文件,可以根据需求调整扩展名条件。
这里演示的是文档级附件筛选。对于页面注释附件,也可以在对应的遍历代码中检查 attachment.FileName,实现相同功能。
需要注意,文件扩展名并不能完全代表实际文件格式。如果 PDF 来源不可信,建议在后续处理前进一步验证提取文件的内容。
七、常见问题
1. PDF 中明明有附件,为什么代码没有提取出来?
首先确认附件属于哪种类型。
如果只使用:
pdf.Attachments
只能获取文档级附件,无法覆盖所有页面注释附件。
如果文件是通过页面上的回形针图标添加的,就需要遍历 pdf.Pages,从各页面的 Annotations 集合中查找附件。
另外,PDF 中指向外部文件的超链接并不属于嵌入式附件,不能通过上述方法直接提取。
2. 多个附件的文件名相同怎么办?
如果直接使用 File.WriteAllBytes() 将附件保存到同一个路径,后保存的文件可能覆盖先前的文件。
可以在保存前检查文件是否已经存在,并为重名文件添加后缀。
本文完整代码中的 GetUniqueFilePath() 方法就实现了这一处理。
如果附件来自不同页面,还可以在文件名前添加页码,方便后续整理和查找。
3. 为什么提取后的附件无法打开?
提取过程会将 PDF 中嵌入的二进制数据保存到文件,但不能保证附件本身有效。
如果提取后的文件无法打开,可以检查:
- 原始附件是否已经损坏。
- 文件扩展名是否与实际内容一致。
- PDF 是否采用了特殊或不受支持的附件结构。
如果源文件本身存在问题,单纯提取附件通常无法修复其内容。
4. 加密的 PDF 可以提取附件吗?
如果 PDF 设置了打开密码,需要先使用正确的密码加载文档,之后才能访问其附件。
对于具有访问限制的 PDF,还应确保拥有相应的读取和提取权限。
5. 从 PDF 中提取的附件可以直接打开吗?
不建议直接打开来源不明的附件,特别是可执行文件、脚本等类型。
如果程序需要批量处理用户上传的 PDF,建议将附件保存到受控目录,并在进一步处理之前进行文件类型验证和安全检查。
总结
使用 C# 提取 PDF 附件时,需要区分文档级附件和页面注释附件。
文档级附件可以通过 PdfDocument.Attachments 获取,而页面注释附件需要逐页遍历注释集合,并识别 PdfAttachmentAnnotationWidget 对象。
本文介绍了这两种附件的提取方法,并通过完整示例实现了附件分类保存、按页命名和文件重名处理。此外,还可以根据扩展名筛选需要提取的文件。
这些方法可以用于项目资料归档、报表附件提取以及文档管理系统中的附件处理,减少手动保存文件的重复操作。