ddn/sapp

简单且通用的PDF解析器(SAPP):使用PHP编辑、操作和签名PDF文档

1.5.1 2024-07-22 08:11 UTC

README

SAPP代表简单且通用的PDF解析器,它如其名一样:解析PDF文件。它还支持其他酷炫的功能,例如重建文档(使内容更清晰或紧凑)或数字签名文档。

SAPP之所以是通用的,是因为它不关心PDF文档的组成(例如添加页面、更新页面等)。相反,它的目标是作为一个后端来解析现有的PDF文档,并操作其上的对象,或者创建新的对象。

使用SAPP的方式可以在签名文档的函数中看到:它是一个独立的函数,用于添加和操作文档中包含的PDF对象。

SAPP的一些功能

  1. 支持1.4版本的PDF文档
  2. 支持1.5版本及以后文档的许多功能(包括交叉引用流)
  3. 使用增量版本工作
  4. 用于重建文档以简化版本(删除旧版本)
  5. 使用Acrobat工作流程签名文档(并获得绿色勾选标记)。
  6. 其他。

1. 为什么选择SAPP

我创建了SAPP,因为我想要程序化地签名文档,包括 多个签名

我尝试了tcpdf以及FPDI,但结果并不如预期。当使用FPDI导入文档时,结果是一个包含原始文档页面的新文档,而不是原始文档。因此,如果原始文档已经签名,那些签名就会丢失。

我阅读了有关SetaPDF-Signer的介绍,但它不能免费使用。我还检查了一些命令行工具,如PortableSigner,但它(1)基于Java(我真的很不喜欢Java),(2)依赖于iText和其他库,这些库目前似乎不是免费的。

最后,我获得了PDF 1.7规范,并了解了增量PDF文档及其在文档中包含多个签名时的实用性。

2. 使用SAPP

2.1 使用composer和packagist

要在您的项目中使用SAPP,您只需使用composer

$ composer require ddn/sapp:dev-main

然后,将创建一个vendor文件夹,然后您可以简单地包含主文件并使用类

use ddn\sapp\PDFDoc;

require_once('vendor/autoload.php');

$obj = PDFDoc::from_string(file_get_contents("/path/to/my/pdf/file.pdf"));
echo $obj->to_pdf_file_s(true);

2.2 获取源代码

或者您可以克隆仓库并使用composer

$ git clone https://github.com/dealfonso/sapp
$ cd sapp
$ composer dump-autoload
$ php pdfrebuild.php examples/testdoc.pdf > testdoc-rebuilt.pdf

然后您就可以准备包含主文件并使用类了。

3. 示例

在源代码的根目录中,您可以找到两个简单的示例

  1. pdfrebuild.php:此示例获取PDF文件,加载它并将其重建,以使每个PDF对象按顺序排列,并减少定义文档所需文本的数量。
  2. pdfsign.php:本例演示如何获取PDF文件并使用pkcs12(pfx)证书对其进行数字签名。
  3. pdfsigni.php:本例演示如何获取PDF文件,使用pkcs12(pfx)证书对其进行数字签名,并在文档中添加图像以使签名可见。
  4. pdfsignx.php:另一个示例,获取PDF文件并使用pkcs12(pfx)证书对其进行数字签名,并在文档中添加图像以使签名可见。
  5. pdfcompare.php:本例比较两个PDF文件,并检查它们之间的差异(按对象、字段逐个检查)。

3.1. 使用pdfrebuild.php重建PDF文件

一旦克隆了仓库并生成了自动加载内容,就可以运行示例

$ php pdfrebuild.php examples/testdoc.pdf > testdoc-rebuilt.pdf

结果是更加有序的PDF文档,可能(problably)更小。(例如,重建examples/testdoc.pdf提供一个50961字节的文档,而原始文档为51269字节)。

$ ls -l examples/testdoc.pdf
-rw-r--r--@ 1 calfonso  staff  51269  5 nov 14:01 examples/testdoc.pdf
$ ls -l testdoc-rebuilt.pdf
-rw-r--r--@ 1 calfonso  staff  50961  5 nov 14:22 testdoc-rebuilt.pdf

并且表看起来有显著的顺序改善

$ cat examples/testdoc.pdf
...
xref
0 39
0000000000 65535 f
0000049875 00000 n
0000002955 00000 n
0000005964 00000 n
0000000022 00000 n
0000002935 00000 n
0000003059 00000 n
0000005928 00000 n
...
$ cat testdoc-rebuilt.pdf
...
xref
0 8
0000000000 65535 f
0000000009 00000 n
0000000454 00000 n
0000000550 00000 n
0000000623 00000 n
0000003532 00000 n
0000003552 00000 n
0000003669 00000 n
...

代码

use ddn\sapp\PDFDoc;

require_once('vendor/autoload.php');

if ($argc !== 2)
    fwrite(STDERR, sprintf("usage: %s <filename>", $argv[0]));
else {
    if (!file_exists($argv[1]))
        fwrite(STDERR, "failed to open file " . $argv[1]);
    else {
        $obj = PDFDoc::from_string(file_get_contents($argv[1]));

        if ($obj === false)
            fwrite(STDERR, "failed to parse file " . $argv[1]);
        else
            echo $obj->to_pdf_file_s(true);
    }
}

3.2. 使用pdfsign.php签署PDF文件

要签署PDF文档,可以使用脚本pdfsign.php

$ php pdfsign.php examples/testdoc.pdf caralla.p12 > testdoc-signed.pdf

现在文档已经签署。如果您想添加第二个签名,只需再次签署生成的文档即可

$ php pdfsign.php testdoc-signed.pdf user.p12 > testdoc-resigned.pdf

代码:(完整工作示例)

use ddn\sapp\PDFDoc;

require_once('vendor/autoload.php');

if ($argc !== 3)
    fwrite(STDERR, sprintf("usage: %s <filename> <certfile>", $argv[0]));
else {
    if (!file_exists($argv[1]))
        fwrite(STDERR, "failed to open file " . $argv[1]);
    else {
        // Silently prompt for the password
        fwrite(STDERR, "Password: ");
        system('stty -echo');
        $password = trim(fgets(STDIN));
        system('stty echo');
        fwrite(STDERR, "\n");

        $file_content = file_get_contents($argv[1]);
        $obj = PDFDoc::from_string($file_content);
        
        if ($obj === false)
            fwrite(STDERR, "failed to parse file " . $argv[1]);
        else {
            if (!$obj->set_signature_certificate($argv[2], $password)) {
                fwrite(STDERR, "the certificate is not valid");
            } else {
                $docsigned = $obj->to_pdf_file_s();
                if ($docsigned === false)
                    fwrite(STDERR, "could not sign the document");
                else
                    echo $docsigned;
            }
        }
    }
}

3.3. 使用pdfsignx.php带图像签署PDF文件

要签署包含与签名关联的图像的PDF文档,可以使用脚本pdfsignx.php

$ php pdfsignx.php examples/testdoc.pdf "https://www.google.es/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" caralla.p12 > testdoc-signed.pdf

现在文档已经签署,出现了一个酷炫的图像。如果您想添加第二个签名,只需再次签署生成的文档即可。

代码:(完整工作示例)

use ddn\sapp\PDFDoc;

require_once('vendor/autoload.php');

if ($argc !== 4)
    fwrite(STDERR, sprintf("usage: %s <filename> <image> <certfile>", $argv[0]));
else {
    if (!file_exists($argv[1]))
        fwrite(STDERR, "failed to open file " . $argv[1]);
    else {
        fwrite(STDERR, "Password: ");
        system('stty -echo');
        $password = trim(fgets(STDIN));
        system('stty echo');
        fwrite(STDERR, "\n");

        $file_content = file_get_contents($argv[1]);
        $obj = PDFDoc::from_string($file_content);
        
        if ($obj === false)
            fwrite(STDERR, "failed to parse file " . $argv[1]);
        else {
            $signedDoc = $obj->sign_document($argv[3], $password, 0, $argv[2]);
            if ($signedDoc === false) {
                fwrite(STDERR, "failed to sign the document");
            } else {
                $docsigned = $signedDoc->to_pdf_file_s();
                if ($docsigned === false)
                    fwrite(STDERR, "could not sign the document");
                else
                    echo $docsigned;
            }
        }
    }
}

3.4. 使用pdfsigni.php带图像签署PDF文件

要签署包含与签名关联的图像的PDF文档,可以使用脚本pdfsigni.php

$ php pdfsigni.php examples/testdoc.pdf "https://www.google.es/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" caralla.p12 > testdoc-signed.pdf

现在文档已经签署,出现了一个酷炫的图像。如果您想添加第二个签名,只需再次签署生成的文档即可。

与之前代码的主要区别在于,在这种情况下,签名被视为编辑文档的阶段,但只有在文档生成后才会签署。因此,可以编辑文档(例如,添加文本或图像)并延迟签名,直到最终生成文档。

代码

*省略了与签名和图像出现位置相关的代码,但可以在文件pdfsigni.php中查看。

...
$obj->set_signature_appearance(0, [ $p_x, $p_y, $p_x + $i_w, $p_y + $i_h ], $image);
if (!$obj->set_signature_certificate($argv[3], $password)) {
    fwrite(STDERR, "the certificate is not valid");
} else {
    $docsigned = $obj->to_pdf_file_s();
    if ($docsigned === false)
        fwrite(STDERR, "could not sign the document");
    else
        echo $docsigned;
}
...

3.5. 使用pdfdeflate.php解压缩任何对象的流

这是一个演示如何操作文档中对象的示例。示例遍历文档中的任何对象,获取其压缩后的流,移除过滤器(即压缩方法),并将其恢复到文档中。这样,对象就不再压缩了(例如,您可以读取绘制元素所使用的命令的纯文本)。

代码

foreach ($obj->get_object_iterator() as $oid => $object) {
    if ($object === false)
        continue;
    if ($object["Filter"] == "/FlateDecode") {
        /* Getting the stream with raw flag set to "false" will process the stream prior to returning it */
        $stream = $object->get_stream(false);
        if ($stream !== false) {
            unset($object["Filter"]);
            /* Setting the stream with raw flag set to "false" will process the stream, although in this case it is not important, because we removed the filter.*/
            $object->set_stream($stream, false);
            $obj->add_object($object);
        }
    }
}
echo $obj->to_pdf_file_s(true);

4. 局限性

目前,主要局限性包括

  • 非零生成PDF对象的初步支持:它们不常见,但根据PDF结构的定义,它们是可能的。如果您发现一个非零生成对象,请发送文档给我,我将尝试支持它。
  • 未处理加密文档
  • 当然,还有其他局限性:)

5. 未来工作

我的想法是提供对其他酷炫特性的支持

  1. 支持TSA时间戳
  2. 包括文档保护(即锁定文档)
  3. 文档加密(http://www.fpdf.org/en/script/script37.php
  4. 尝试提供写入文本的支持

6. 属性

  1. 计算签名散列的机制深受tcpdf的启发。
  2. 读取jpg和png文件是从fpdf中获取的。