各位编程界的侠客、程序猿、以及试图用代码征服世界的架构师们,大家好!
今天我们要聊的话题,听起来有点严肃,但我会用最轻松的语气带大家穿过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需要的是:
- 字形数据:告诉你‘中’字长什么样。
- 字符映射表:告诉你‘中’字对应的ID号是多少。
- 宽度信息:因为中文字符宽度不固定,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.ufm 和 simhei.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() 函数。
核心逻辑:
- 移动坐标原点到签名的位置。
- 执行旋转操作。
- 插入图片(此时图片是绕着原点转的)。
- 恢复坐标原点。
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。但这超出了“生成”的范畴,属于“安全验证”的范畴。今天我们只聊生成,聊签名显示。
第九章:总结与展望
好了,各位听众,今天的讲座即将接近尾声。
我们回顾一下:
- 选对工具:TCPDF 是处理中文PDF的王者。
- 搞定字体:中文字体不是直接丢进去就能用的,你需要
ttf2ufm来制作映射表。 - 优雅排版:利用
MultiCell、Rotate和自定义的Header/Footer函数,打造专业的文档结构。 - 电子签名:理解PDF的坐标系,利用
SetXY和Rotate实现签名的精准定位。
从现在开始,当你看到满屏的 □□□ 时,你应该会会心一笑,拿出手机打开终端,运行那句魔法咒语:
ttf2ufm -a -o xxx.ufm xxx.ttf
然后敲下代码,生成一份完美无缺的PDF合同。
技术就是这样,它枯燥,它繁琐,它充满了报错信息。但它也是强大的。它能将你的思想,通过0和1的代码,变成一份可以在纸张上流传千年的契约。
如果你在操作中遇到问题,不要慌。TCPDF的报错信息有时候很详细,有时候又像是在写诗。多看文档,多试错,你终将成为那个用PHP掌控PDF的“魔导士”。
现在,去写代码吧,让合同飞一会儿!
(讲座结束,掌声雷动……如果有的话)