C# 提取 PDF 附件:包括文档附件和页面注释附件

0 阅读8分钟

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​​ 对象。

本文介绍了这两种附件的提取方法,并通过完整示例实现了附件分类保存、按页命名和文件重名处理。此外,还可以根据扩展名筛选需要提取的文件。

这些方法可以用于项目资料归档、报表附件提取以及文档管理系统中的附件处理,减少手动保存文件的重复操作。