引言
后台系统里「生成发票 PDF」「导出 Excel 报表」「把扫描件 OCR 成文本」「给文档打水印」这类需求几乎无处不在。它们看着零散,实则属于同一类工程问题:把结构化数据渲染成文档、或把文档解析回结构化数据。PHP 在这块生态相当成熟,但选型混乱——光 PDF 生成就有 Dompdf、mPDF、TCPDF、FPDF 四个主流库,各自的中文支持、CSS 兼容性、性能差异巨大。
本文按「PDF / Office / OCR / 条码」四条线梳理 PHP 文档处理:先给出 PDF 生成库的选型矩阵与 HTML 转 PDF 的坑,再讲 PDF 合并拆分水印、文本提取、Excel/Word 读写、OCR 识别与二维码生成,最后落到大文档处理时的内存、超时与异步流水线治理。
目录
- 1. 文档处理全景与选型思路
- 2. PDF 生成:HTML 转 PDF
- 3. 中文与字体:最常见的坑
- 4. PDF 操作:合并、拆分、水印、表单
- 5. PDF 解析与文本提取
- 6. Office 文档:Excel 与 Word
- 7. OCR 与条码识别
- 8. 大文档治理与异步流水线
- 延伸阅读
1. 文档处理全景与选型思路
1.1 四类任务
| 类别 | 输入 → 输出 | 代表库 |
|---|---|---|
| PDF 生成 | HTML/数据 → PDF | Dompdf、mPDF、TCPDF、FPDF |
| PDF 操作 | PDF → PDF | FPDI、SetaPDF、pdftk(外部) |
| Office 读写 | 数据 ↔ xlsx/docx | PhpSpreadsheet、PhpWord |
| 图像识别 | 图像 → 文本/码 | Tesseract、zxing、endroid/qr-code |
1.2 纯 PHP vs 外部进程
PHP 库的优点是「零外部依赖、随应用部署」;缺点是性能与排版精度不如原生工具。当排版要求极高或量极大时,宁可调外部进程:
# 高保真 HTML → PDF
wkhtmltopdf --enable-local-file-access report.html report.pdf
# 或用无头浏览器
chromium --headless --print-to-pdf=report.pdf report.html
选型的第一问永远是:排版要求高不高、量有多大。内部报表用 Dompdf 足够;对外的高保真 PDF 用无头 Chrome;OCR、复杂图像处理交给外部进程。
记忆:纯 PHP 库胜在零依赖、易部署;高保真/高吞吐场景调外部工具(wkhtmltopdf、chromium、tesseract)。
2. PDF 生成:HTML 转 PDF
2.1 主流库对比
| 库 | HTML/CSS 支持 | 中文 | 性能 | 适用 |
|---|---|---|---|---|
| Dompdf | 中(CSS 2.1 子集) | 需字体 | 中 | 简单报表、发票 |
| mPDF | 较好(含部分 CSS3) | 好(内建) | 较慢 | 复杂排版、中文文档 |
| TCPDF | 弱(自己排版) | 好 | 快 | 精确定位、条码 |
| FPDF | 无(纯坐标绘制) | 需扩展 | 最快 | 极简、自定义 |
Dompdf 的定位是「拿现成 HTML 直接渲染」,最省事;mPDF 的 CSS 与中文支持更好但更慢、更吃内存;TCPDF/FPDF 不解析 HTML,用坐标绘制,性能最好但开发成本高。
2.2 Dompdf 起步
composer require dompdf/dompdf
<?php
use Dompdf\Dompdf;
use Dompdf\Options;
$options = new Options();
$options->set('isRemoteEnabled', true); // 允许加载远程图片
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$dompdf->stream('invoice.pdf', ['Attachment' => true]);
2.3 mPDF 起步
<?php
require_once __DIR__ . '/vendor/autoload.php';
$mpdf = new \Mpdf\Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'margin_top' => 20,
]);
$mpdf->WriteHTML('<h1>中文标题</h1><p>正文内容</p>');
$mpdf->Output('report.pdf', \Mpdf\Output\Destination::DOWNLOAD);
2.4 常见渲染差异
- 分页:
page-break-before: always在 Dompdf/mPDF 都支持,但break-inside: avoid支持度不同; - 浮动与 flex:两者对
flexbox支持都不完整,复杂布局要用表格模拟; - 背景色与渐变:mPDF 支持渐变,Dompdf 有限;
- 单位:
rem/vw支持不稳,坚持用px/mm/pt。
记忆:Dompdf 省事、mPDF 中文与 CSS 好但慢、TCPDF/FPDF 快但要手写坐标;复杂布局别指望 flex,用表格。
3. 中文与字体:最常见的坑
3.1 为什么中文会变方块
PDF 库默认字体(Helvetica 等)不含中文字形,遇到中文就渲染成「豆腐块」。解决方式是嵌入中文字体。
3.2 Dompdf 嵌入中文字体
$options->set('defaultFont', 'Noto Sans CJK SC');
// 把字体文件放进 dompdf 的字体目录,或用 CSS @font-face 指定
@font-face {
font-family: 'Noto Sans CJK SC';
src: url('/fonts/NotoSansCJKsc-Regular.otf');
}
body { font-family: 'Noto Sans CJK SC', sans-serif; }
3.3 mPDF 中文配置
$mpdf = new \Mpdf\Mpdf([
'mode' => 'utf-8',
'autoScriptToLang' => true,
'autoLangToFont' => true, // 自动为中文切换字体
]);
autoLangToFont 让 mPDF 自动识别中英混排并切换字体,省去手动指定。
3.4 字体嵌入的代价
| 影响 | 说明 |
|---|---|
| PDF 体积 | 嵌入 CJK 字体可让 PDF 从几十 KB 涨到数 MB |
| 生成耗时 | 字体子集化需要 CPU |
| 缓存 | 字体解析结果应缓存,避免每次重解析 |
**只嵌入用到的字形(子集化)**是控制体积的关键,mPDF 与 Dompdf 都支持子集化,但需正确配置。
记忆:中文方块 = 字体未嵌入;CJK 字体子集化能控制体积;中英混排用
autoLangToFont(mPDF)。
4. PDF 操作:合并、拆分、水印、表单
4.1 合并与拆分(FPDI)
FPDI 能读取已有 PDF 页面作为「模板」插入新文档:
composer require setasign/fpdi
<?php
use setasign\Fpdi\Fpdi;
$pdf = new Fpdi();
$files = ['a.pdf', 'b.pdf', 'c.pdf'];
foreach ($files as $f) {
$count = $pdf->setSourceFile($f);
for ($i = 1; $i <= $count; $i++) {
$tpl = $pdf->importPage($i);
$pdf->AddPage();
$pdf->useTemplate($tpl);
}
}
$pdf->Output('merged.pdf', 'D');
注意:FPDI 只能处理**未加密、未压缩对象流(Object Stream)**的 PDF。很多现代 PDF 用了对象流压缩,需先用 qpdf --object-streams=disable 解压。
4.2 加文字水印
$pdf = new Fpdi();
$pdf->setSourceFile('source.pdf');
$tpl = $pdf->importPage(1);
$pdf->AddPage();
$pdf->useTemplate($tpl);
$pdf->SetFont('Helvetica', 'B', 40);
$pdf->SetTextColor(255, 0, 0);
$pdf->SetAlpha(0.3); // 半透明
$pdf->Rotate(45, 105, 148); // 45 度旋转,居中
$pdf->Text(40, 155, 'CONFIDENTIAL');
$pdf->Output('watermarked.pdf', 'D');
4.3 表单填充
AcroForm 表单可用 FPDI 的 setasign/fpdi-tcpdf + FPDI 的 setField 或 SetaPDF-FormFiller(商业)填充。免费方案对表单支持有限,字段类型(复选框、下拉)处理复杂,需充分测试。
记忆:FPDI 做合并/拆分/水印/贴图;只能吃「无对象流压缩」的 PDF,必要时先
qpdf解压;表单填充免费方案支持有限。
5. PDF 解析与文本提取
5.1 Smalot PDFParser
composer require smalot/pdfparser
<?php
use Smalot\PdfParser\Parser;
$parser = new Parser();
$pdf = $parser->parseFile('/path/invoice.pdf');
$text = $pdf->getText(); // 提取全文
$pages = $pdf->getPages();
echo $pages[0]->getText(); // 单页文本
5.2 提取的局限
| 情况 | 结果 |
|---|---|
| 文本型 PDF(可选中) | 能提取到文本 |
| 扫描件(图片) | 提取为空,需 OCR |
| 多栏排版 | 顺序可能错乱 |
| 表格 | 只有文字,丢失结构 |
「提取为空」几乎总是因为这是扫描件——它是图片,没有文本层,必须走 OCR。
5.3 表格提取
表格结构无法从纯文本可靠还原,通常需要按坐标聚类。Smalot\PdfParser 提供 getDataTm() 拿到带坐标的文本块,可自行按 x/y 聚类成行列:
$pages[0]->getDataTm(); // [ [x, y, text], ... ] 按坐标自行分组
商业方案(如 SetaPDF-Extractor、pdfplumber(Python))对表格支持更好。跨语言时,用 Python 的 pdfplumber 处理表格、PHP 负责调度,是常见分工。
记忆:文本型 PDF 用 Smalot 提取;扫描件提取为空要 OCR;表格结构需按坐标聚类还原。
6. Office 文档:Excel 与 Word
6.1 PhpSpreadsheet 读写 Excel
composer require phpoffice/phpspreadsheet
<?php
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
$sheet = new Spreadsheet();
$s = $sheet->getActiveSheet();
$s->setCellValue('A1', '姓名')->setCellValue('B1', '金额');
$s->setCellValue('A2', '张三')->setCellValue('B2', 1200);
$writer = new Xlsx($sheet);
$writer->save('report.xlsx'); // 或 php://output 直接下载
读取大 Excel 时务必用只读模式 + 按块读,否则内存暴涨:
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader('Xlsx');
$reader->setReadDataOnly(true); // 只读数据,忽略样式
$spreadsheet = $reader->load('big.xlsx');
// 分块读(每次 100 行)
foreach ($spreadsheet->getActiveSheet()->getRowIterator(1, 100) as $row) { /* ... */ }
6.2 PhpWord 生成 Word
<?php
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
$word = new PhpWord();
$section = $word->addSection();
$section->addTitle('合同标题', 1);
$section->addText('甲方:某公司', ['size' => 12, 'name' => '宋体']);
IOFactory::createWriter($word, 'Word2007')->save('contract.docx');
6.3 格式选择的取舍
| 格式 | 库 | 说明 |
|---|---|---|
| xlsx | PhpSpreadsheet | 读写皆可,最常用 |
| csv | 内置 fputcsv | 大导出首选,无内存压力 |
| docx | PhpWord | 生成 Word |
| 模板替换 | PhpWord/TemplateProcessor | 用现成 docx 模板填变量 |
超大导出(十万行以上)不要用 xlsx,改用 CSV 流式写出,或先落盘再异步通知下载。
记忆:xlsx 用 PhpSpreadsheet(大文件开只读 + 分块);超大导出改用 CSV 流式;docx 用 PhpWord 或模板替换。
7. OCR 与条码识别
7.1 Tesseract OCR
PHP 本身不做 OCR,通常调用外部 tesseract 二进制:
$image = '/tmp/scan.png';
$out = shell_exec('tesseract ' . escapeshellarg($image) . ' stdout -l chi_sim+eng 2>/dev/null');
echo $out;
# 安装语言包
brew install tesseract tesseract-lang # macOS
apt-get install tesseract-ocr tesseract-ocr-chi-sim
-l chi_sim+eng 表示同时识别简体中文与英文。OCR 前先做图像预处理(灰度、二值化、去噪、纠偏)能显著提升准确率:
// 用 Imagick 预处理
$im = new Imagick('/tmp/scan.png');
$im->setImageColorspace(Imagick::COLORSPACE_GRAY);
$im->thresholdImage(0.5); // 二值化
$im->deskewImage(1.0); // 纠偏
$im->writeImage('/tmp/scan_clean.png');
7.2 二维码与条码
composer require endroid/qr-code
<?php
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
$qr = new QrCode('https://example.com/order/1001');
$result = (new PngWriter())->write($qr);
header('Content-Type: ' . $result->getMimeType());
echo $result->getString();
条码可用 picqer/php-barcode-generator:
$generator = new \Picqer\Barcode\BarcodeGeneratorPNG();
file_put_contents('barcode.png', $generator->getBarcode('1234567890', $generator::TYPE_CODE_128));
7.3 识别二维码(从上传图片)
composer require khanamiryan/qrcode-detector-decoder
上传图片后解码其中的二维码,常用于「扫码登录」「扫码支付回调」场景。
记忆:OCR 调外部 tesseract,先做灰度/二值化/纠偏预处理;二维码用 endroid/qr-code 生成、用解码库识别。
8. 大文档治理与异步流水线
8.1 内存与超时
文档处理是内存与 CPU 密集型,必须在长请求之外隔离:
memory_limit = 512M ; 大文档处理单独提高
max_execution_time = 300 ; 或 CLI 下设为 0(不限)
Web 请求里同步处理大文档几乎必然超时,正确做法是丢进队列异步处理。
8.2 队列化流水线
// 控制器:只入队,立即返回
GenerateInvoicePdf::dispatch($orderId)->onQueue('documents');
// 队列任务:真正生成
final class GenerateInvoicePdf implements ShouldQueue
{
public int $timeout = 300;
public int $tries = 3;
public function handle(PdfRenderer $renderer): void
{
$pdf = $renderer->render($this->orderId);
Storage::put("invoices/{$this->orderId}.pdf", $pdf);
}
}
生成完成后通过通知或 WebSocket 告诉前端「下载就绪」,而不是让用户在请求里干等。
8.3 资源隔离与限流
| 措施 | 目的 |
|---|---|
独立队列(documents) | 不拖垮普通任务 |
| 独立 worker(更大内存) | 大文档专用 |
| 并发上限 | 防止内存叠加 OOM |
| 临时文件清理 | 避免磁盘写满 |
# 专用 worker,限制并发为 2
php artisan queue:work --queue=documents --memory=768 --max-jobs=50
8.4 监控
把「生成耗时、失败率、队列积压」打进指标,配合 可观测性 里的告警体系,才能在文档流水线出问题时第一时间发现。
记忆:大文档必须异步队列化:控制器入队即返回、独立队列 + 大内存 worker + 并发上限;生成完通知用户而非同步等待。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。