PHP如何生成PDF合同并支持中文字体与电子签名功能

各位编程界的侠客、程序猿、以及试图用代码征服世界的架构师们,大家好!

今天我们要聊的话题,听起来有点严肃,但我会用最轻松的语气带大家穿过PDF生成的“修罗场”。

你们有没有经历过这种崩溃时刻:你在屏幕上敲出一行完美的中文合同条款,那是你对技术的信仰,是代码的尊严。然后,你点击“生成PDF”,满怀期待地打开文件,结果——

满屏的“□□□”或者乱码,像是一群愤怒的小蚂蚁在啃噬你的视网膜。又或者,合同生成出来了,排版乱得像刚被狗啃过的骨头,至于电子签名?哈,那更是要了你老命,签名像是在跳舞,完全不在纸张上。

别慌。今天,作为你们的技术向导,我就手把手带大家搭建一套“PHP全能PDF生成器”。我们不仅要搞定中文字体这个“隐形杀手”,还要搞定电子签名这个“旋转魔术”。准备好了吗?让我们把技术变成一种艺术,一种让老板看了想流泪、客户看了想盖章的艺术。


第一章:PDF与PHP的浪漫邂逅

首先,我们要选对武器。在PHP的世界里,处理PDF的库就像电影里的演员一样,有的戏多,有的戏少。

  • FPDF:这是“老戏骨”,轻量级,控制力强。但它的缺点也很明显:对于中文这种复杂的Unicode字符,它基本就是个“文盲”。你需要自己搞字体映射,稍有不慎,就是一堆乱码。
  • TCPDF:这是“当红小生”。它底层基于FPDF,但支持UTF-8,内置了大量的PDF功能。对于中文合同这种复杂的排版需求,TCPDF是当之无愧的“主厨”。
  • DomPDF:这是“滤镜大师”,基于HTML/CSS。但如果你要生成像法律合同这样严格的、需要固定页眉页脚的文档,HTML转PDF经常会出现布局崩塌,它的CSS渲染引擎有时候就像是喝高了似的。

结论:今天我们的主角是 TCPDF。它稳重、靠谱,而且文档虽然厚得像砖头,但真香。


第二章:中文字体的“翻译官”难题

为什么中文字体这么难搞?因为PDF是给机器看的,而中文是给人类看的。

在PDF的世界里,字体文件(.ttf, .otf)通常很大,但PDF本身不直接“认识”这些文件。PDF需要的是:

  1. 字形数据:告诉你‘中’字长什么样。
  2. 字符映射表:告诉你‘中’字对应的ID号是多少。
  3. 宽度信息:因为中文字符宽度不固定,PDF需要精确计算每行的断点。

所以,第一步,我们需要一个“翻译官”。这个工具叫 ttf2ufm(TrueType to Unicode Font Metrics)。

1. 准备工作:寻找你的“英雄”

你需要一个中文字体文件,比如思源黑体、宋体,或者随便什么你电脑里好用的ttf。假设你有一个叫 simhei.ttf 的文件。

2. 制作映射表

你需要安装 ttf2ufm 工具。在Mac上它是自带或者通过brew安装的,在Windows上你可以下载个Windows版本。

打开终端(或命令行),输入以下神咒:

ttf2ufm -a -o simhei.ufm simhei.ttf
  • -a:生成ASCII映射表。
  • -o:输出文件。
  • simhei.ttf:你的输入文件。

这就好比你在告诉翻译软件:“嘿,把这本字典(simhei.ttf)里的所有汉字,都给我翻出页码和详细说明来(simhei.ufm)。”

3. 编译成PHP格式

光有ufm文件还不行,TCPDF还要一种压缩格式(.z)。你需要用TCPDF自带的工具来转换:

php tcpdf/tools/ttf2tcpdf.php -i simhei.ttf simhei.z

这时候,你的目录里应该多了两个文件:simhei.ufmsimhei.z。现在,TCPDF终于可以“读”懂这个字体的发音和字形了。


第三章:编写“合同生成剧本”

好了,现在我们有武器(TCPDF),有翻译官(字体文件)。让我们开始写代码。

为了演示,我们假设我们在生成一份《劳动合同》。

3.1 基础搭建

我们创建一个名为 ContractGenerator.php 的文件。

<?php

// 引入TCPDF
require_once('tcpdf.php');

// 初始化PDF对象
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);

// 设置页边距
$pdf->SetMargins(15, 20, 15); // 左,上,右

// 设置默认字体
// 'simhei' 是我们在第二章里准备的字体名称,必须和生成的 .ufm 文件名对应
$pdf->SetFont('simhei', '', 12); 

3.2 填充内容

这里有个坑,很多新手会犯。TCPDF默认不会自动换行。如果你的合同文本很长,你需要手动处理。但TCPDF也提供了封装好的方法,比如 MultiCell

// 添加一页
$pdf->AddPage();

// 设置标题
$pdf->SetFontSize(20);
$pdf->SetFont('simhei', 'B', 20); // B代表粗体
$pdf->Cell(0, 10, '劳动合同', 0, 1, 'C'); // 0,10,0,1,C

// 重置字体
$pdf->SetFontSize(12);
$pdf->SetFont('simhei', '', 12);

// 内容文本
$contractText = "甲方(用人单位):[公司名称]nn乙方(劳动者):[员工姓名]nn鉴于甲方需要聘用乙方,双方本着平等自愿、协商一致的原则,达成如下协议:nn第一条 工作内容n乙方同意在甲方从事[职位名称]工作。nn第二条 劳动期限n本合同期限为三年,自2023年1月1日起至2025年12月31日止。nn...(此处省略后续条款)...";

// MultiCell 是关键!它支持自动换行和多行文本
// 参数:宽, 高, 内容, 边框, 换行, 对齐, 填充, 跳过页码
$pdf->MultiCell(0, 10, $contractText, 0, 'J', 1, 1); 

// 添加页码
$pdf->AliasNbPages();
$pdf->Cell(0, 10, 'Page ' . $pdf->PageNo() . '/{nb}', 0, 1, 'R');

3.3 难搞的页眉页脚

法律合同最烦人的就是每一页都要有页眉(公司Logo和合同名称)和页脚(页码)。

TCPDF有一个专门的方法来处理这个。我们重写 Header()Footer() 函数。

// 重写Header
public function Header() {
    // 1. 画个Logo(这里用个简单的矩形代替,实际可以用 $this->Image() 加载图片)
    $this->Rect(15, 15, 30, 30, 'D'); 

    // 2. 写合同标题
    $this->SetFont('simhei', 'B', 16);
    $this->Cell(0, 0, '保密协议书', 0, 1, 'C');

    // 3. 写一条细线
    $this->Line(15, 55, 195, 55); // x1, y1, x2, y2
}

// 重写Footer
public function Footer() {
    $this->SetY(-15); // 距离底部15mm
    $this->SetFont('simhei', '', 10);
    $this->Cell(0, 10, '本合同由双方签署后生效。', 0, 1, 'C');
    $this->Cell(0, 10, '第 ' . $this->PageNo() . ' 页', 0, 0, 'R');
}

第四章:电子签名的“旋转魔法”

这是今天最精彩的部分。你手里有一张签好名的图片(.png或.jpg)。当你把它放在Word里,它是平的。但当你把它放在PDF里,客户通常需要它是竖着的(因为传统的纸质签名是竖着拿笔写的,或者是为了符合纸张的阅读习惯)。

如果在PDF里直接放一张横着的签名,那就像是在教堂里跳霹雳舞,非常违和。

4.1 理解坐标系

PDF的坐标系原点 (0,0)左下角
X轴向右增长。
Y轴向上增长。

当我们用 Image() 函数插入图片时,默认是矩形的,方向是0度(水平)。

4.2 旋转算法

我们需要让图片旋转90度(或270度)。TCPDF 提供了 Rotate() 函数。

核心逻辑:

  1. 移动坐标原点到签名的位置。
  2. 执行旋转操作。
  3. 插入图片(此时图片是绕着原点转的)。
  4. 恢复坐标原点。

4.3 代码实战

假设我们要在合同底部留出签名空间。

// 移动原点到签名区域的中心点
// 假设原点在 (100, 150) 位置,我们要在这里旋转签名
$pdf->SetXY(100, 150); 

// 旋转90度
$pdf->Rotate(90, 100, 150);

// 插入图片
// 注意:Image的第一个参数是文件名,第二个是x(旋转中心),第三个是y(旋转中心),第四个是宽,第五个是高
// 宽度和高度在这里是相对旋转后的坐标系来说的,通常为了方便,可以直接设为 0 让它保持比例,或者指定像素值
$pdf->Image('signature.png', 100, 150, 80, 0, 'PNG');

// 恢复旋转(非常重要!否则后面所有内容都会跟着旋转)
$pdf->Rotate(0);

这里有个进阶技巧: 如果你的签名图片本身已经是竖着的,你就不需要旋转。如果客户要求“竖签”,你通常需要把图片旋转90度再放进去,或者用上面那段代码。

4.4 双人签名版

通常合同需要双方签字。甲方在左,乙方在右。

// 1. 甲方签名(左侧,竖着签)
$pdf->SetXY(40, 180); 
$pdf->Rotate(90, 40, 180);
$pdf->Image('signature_employer.png', 40, 180, 60, 0, 'PNG');
$pdf->Rotate(0);

// 添加“甲方”文字
$pdf->SetXY(40, 240); 
$pdf->SetFont('simhei', '', 12);
$pdf->Cell(0, 0, '甲方(盖章):', 0, 1, 'C');

// 2. 乙方签名(右侧,竖着签)
// 注意:这里的坐标计算比较 tricky,通常需要调整x,y
$pdf->SetXY(140, 180); 
$pdf->Rotate(90, 140, 180);
$pdf->Image('signature_employee.png', 140, 180, 60, 0, 'PNG');
$pdf->Rotate(0);

// 添加“乙方”文字
$pdf->SetXY(140, 240); 
$pdf->SetFont('simhei', '', 12);
$pdf->Cell(0, 0, '乙方(签字):', 0, 1, 'C');

第五章:中文字体的“终极优化”

到了这一步,你的代码可能跑通了,但你可能会发现,有时候中文显示是正常的,有时候又是“□□□”。这是为什么?

5.1 字符集的诅咒

PHP文件本身可能是UTF-8编码,但有时候服务器配置(如Apache的AddDefaultCharset)会强制使用GBK。一旦编码不匹配,PHP会把中文字符当成乱码传给TCPDF,TCPDF就懵了:“这玩意儿谁认识?”

解决方法:在文件的最顶端强制声明编码。

<?php
header('Content-Type: text/html; charset=utf-8');
// ... 后续代码

5.2 绝对路径与相对路径

当你使用 Image() 加载中文字体文件或者图片时,路径问题会让你怀疑人生。

TCPDF默认的根目录通常是它自己的根目录。如果你的字体文件在 ./fonts/simhei.ttf,你的图片在 ./assets/sign.png,请务必使用 绝对路径 或者 相对于当前脚本的相对路径

如果报错说找不到字体,99%的情况是路径写错了。

5.3 字体加载配置

TCPDF 有一个自动加载字体的机制。如果你把字体文件放在 tcpdf/fonts/ 目录下,并且在 tcpdf_config.php 里配置了路径,它会自动加载。

但为了保险起见,你可以在代码里手动指定字体目录(虽然TCPDF默认就支持这个)。


第六章:高级排版与打印优化

合同不是一张纸,它可能是几十页。如何让打印效果完美?

6.1 A4纸张设置

$pdf->SetMargins(10, 10, 10); // 打印机通常需要更大的边距以防止裁剪
$pdf->SetAutoPageBreak(true, 15); // 自动分页,距离底部15mm

6.2 水印

有时候合同需要显示“草稿”或“机密”。

// 透明度设置
$pdf->SetAlpha(0.5); 
$pdf->SetFont('simhei', '', 40, '', true);
$pdf->Cell(0, 0, '草稿', 0, 1, 'C', 0, '', 0, 'M', true);
// 恢复透明度
$pdf->SetAlpha(1.0); 

6.3 图片与文字混排

如果合同里包含公司Logo和条款:

// 图片
$pdf->Image('logo.png', 15, 15, 15, 15, 'PNG'); // 宽15,高15,保持比例
// 文字
$pdf->SetFont('simhei', 'B', 12);
$pdf->Text(35, 20, '合同编号:NO-2023001');

第七章:实战项目——一个完整的合同生成器

让我们把这些知识点串联起来。我们将创建一个类 ContractManager,让代码更加模块化。

<?php

require_once('tcpdf.php');

class ContractManager extends TCPDF {

    private $fontPath = 'fonts/'; // 字体目录
    private $logoPath = 'assets/logo.png';
    private $companyName = '未来科技有限公司';
    private $contractTitle = '技术服务协议';

    public function __construct() {
        parent::__construct();
        $this->SetCreator('PHP Contract System');
        $this->SetTitle($this->contractTitle);
        $this->SetSubject('Legal Document');

        // 加载中文字体
        // 这里假设字体文件在 tcpdf/fonts/ 下
        $this->SetFont('simhei', '', 12, $this->fontPath);

        // 注册页眉页脚
        $this->SetHeaderData('', 0, $this->contractTitle, $this->companyName);
        $this->SetFooterData('', 0, 'Generated by PHP System');
    }

    // 重写页眉
    public function Header() {
        // Logo
        $this->Image($this->logoPath, 15, 10, 20, 20, 'PNG', 'http://example.com', 'L', false, 300, 'T', 'L', false, false, 0);

        // 页眉文字
        $this->SetFont('simhei', 'B', 14);
        $this->SetY(15);
        $this->Cell(0, 0, $this->contractTitle, 0, 1, 'L');

        // 线条
        $this->Line(15, 35, 195, 35);
    }

    // 重写页脚
    public function Footer() {
        $this->SetY(-15);
        $this->SetFont('simhei', '', 10);
        $this->Cell(0, 10, '第 ' . $this->PageNo() . ' 页', 0, 0, 'R');
    }

    public function generateContract($data) {
        $this->AddPage();

        // 1. 双方信息
        $this->SetFont('simhei', '', 12);
        $this->MultiCell(0, 8, "甲方:{$data['partyA']}", 0, 'L', 0, 1);
        $this->MultiCell(0, 8, "乙方:{$data['partyB']}", 0, 'L', 0, 1);

        // 2. 协议正文
        $this->Ln(10);
        $this->SetFillColor(240, 240, 240);
        $this->MultiCell(0, 8, $data['content'], 0, 'J', 1, 1);

        // 3. 附件列表(演示多列)
        $this->Ln(10);
        $this->Cell(0, 0, '附件列表:', 0, 1, 'L');
        $this->MultiCell(60, 6, '1. 身份证复印件', 0, 'L', 0, 0);
        $this->MultiCell(60, 6, '2. 公司营业执照', 0, 'L', 0, 0);
        $this->MultiCell(60, 6, '3. 履约承诺书', 0, 'L', 0, 1);

        // 4. 签字区
        $this->AddPage(); // 签字通常在下一页

        $this->SetY(180);
        $this->Cell(0, 0, '甲方代表签字:', 0, 1, 'L');
        $this->Cell(0, 0, '日期:' . date('Y-m-d'), 0, 1, 'L');

        // 甲方签名
        $this->SetXY(90, 170); 
        $this->Rotate(90, 90, 170);
        $this->Image($data['sigA'], 90, 170, 70, 0, 'PNG');
        $this->Rotate(0);

        $this->SetY(200);
        $this->Cell(0, 0, '乙方代表签字:', 0, 1, 'L');
        $this->Cell(0, 0, '日期:' . date('Y-m-d'), 0, 1, 'L');

        // 乙方签名
        $this->SetXY(170, 170); 
        $this->Rotate(90, 170, 170);
        $this->Image($data['sigB'], 170, 170, 70, 0, 'PNG');
        $this->Rotate(0);

        return $this->Output('contract.pdf', 'S'); // 返回二进制流
    }
}

// --- 使用示例 ---
$contract = new ContractManager();

$result = $contract->generateContract([
    'partyA' => '北京某科技有限公司',
    'partyB' => '张三个人',
    'content' => '本协议旨在规范双方的合作关系,确保甲乙双方在履行各自义务的同时,维护合法权益...(此处省略一万字法律条文)...',
    'sigA' => 'sign_a.png',
    'sigB' => 'sign_b.png'
]);

// 保存到本地或直接输出给浏览器
file_put_contents('final_contract.pdf', $result);
echo "合同生成成功!";

第八章:常见问题与“排雷”指南

作为一个资深专家,我必须提醒你,这条路上有很多暗礁。

1. 图片模糊怎么办?
TCPDF 默认会压缩图片以减小PDF体积。如果你打印出来照片是马赛克,检查一下 Image 的参数。把压缩质量设高一点,比如:

$pdf->Image('signature.png', $x, $y, $w, $h, 'PNG', '', '', 0, 'T', 'L', false, false, 95);
// 最后一个参数是 compression quality (0-100)

2. 签名图片被截断了?
当你旋转图片时,一定要把坐标原点(SetXY 的那个点)设置在图片旋转中心的附近,而不是图片的左上角。如果旋转中心点在外面,图片会被截断。

3. 内存溢出?
如果你的合同有几百页,每页都插入大量图片,PHP可能会内存溢出(Fatal Error)。解决方法是在循环中及时 unset() 变量,或者使用 TCPDF_STATIC 的批处理方法(虽然这很复杂,这里就不展开了,建议分批生成)。

4. 如何让签名看起来像真的?
纯图片签名最大的问题是无法防伪(容易被P图)。如果你是做企业级应用,电子签名最好的方式是CA数字证书。这需要用到 PHP 的 OpenSSL 扩展,对图片进行数字签名加密,生成一个带有时间戳的PDF。但这超出了“生成”的范畴,属于“安全验证”的范畴。今天我们只聊生成,聊签名显示。


第九章:总结与展望

好了,各位听众,今天的讲座即将接近尾声。

我们回顾一下:

  1. 选对工具:TCPDF 是处理中文PDF的王者。
  2. 搞定字体:中文字体不是直接丢进去就能用的,你需要 ttf2ufm 来制作映射表。
  3. 优雅排版:利用 MultiCellRotate 和自定义的 Header/Footer 函数,打造专业的文档结构。
  4. 电子签名:理解PDF的坐标系,利用 SetXYRotate 实现签名的精准定位。

从现在开始,当你看到满屏的 □□□ 时,你应该会会心一笑,拿出手机打开终端,运行那句魔法咒语:
ttf2ufm -a -o xxx.ufm xxx.ttf

然后敲下代码,生成一份完美无缺的PDF合同。

技术就是这样,它枯燥,它繁琐,它充满了报错信息。但它也是强大的。它能将你的思想,通过0和1的代码,变成一份可以在纸张上流传千年的契约。

如果你在操作中遇到问题,不要慌。TCPDF的报错信息有时候很详细,有时候又像是在写诗。多看文档,多试错,你终将成为那个用PHP掌控PDF的“魔导士”。

现在,去写代码吧,让合同飞一会儿!

(讲座结束,掌声雷动……如果有的话)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注