图像与文档处理

PHP 图像与文档处理实战:PDF 生成(Dompdf/mPDF/TCPDF 对比与 HTML 转 PDF)、PDF 合并拆分水印与表单(FPDI)、文本提取(Smalot PDFParser)、Office 文档读写(PhpSpreadsheet/PhpWord)、Tesseract OCR 图像识别、二维码条码生成、大文档内存超时治理与异步队列化流水线。

引言

后台系统里「生成发票 PDF」「导出 Excel 报表」「把扫描件 OCR 成文本」「给文档打水印」这类需求几乎无处不在。它们看着零散,实则属于同一类工程问题:把结构化数据渲染成文档、或把文档解析回结构化数据。PHP 在这块生态相当成熟,但选型混乱——光 PDF 生成就有 Dompdf、mPDF、TCPDF、FPDF 四个主流库,各自的中文支持、CSS 兼容性、性能差异巨大。

本文按「PDF / Office / OCR / 条码」四条线梳理 PHP 文档处理:先给出 PDF 生成库的选型矩阵与 HTML 转 PDF 的坑,再讲 PDF 合并拆分水印、文本提取、Excel/Word 读写、OCR 识别与二维码生成,最后落到大文档处理时的内存、超时与异步流水线治理。

前置阅读:文件上传与图片处理 、队列与任务调度 。


目录


1. 文档处理全景与选型思路

1.1 四类任务

类别输入 → 输出代表库
PDF 生成HTML/数据 → PDFDompdf、mPDF、TCPDF、FPDF
PDF 操作PDF → PDFFPDI、SetaPDF、pdftk(外部)
Office 读写数据 ↔ xlsx/docxPhpSpreadsheet、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 格式选择的取舍

格式库说明
xlsxPhpSpreadsheet读写皆可,最常用
csv内置 fputcsv大导出首选,无内存压力
docxPhpWord生成 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 + 并发上限;生成完通知用户而非同步等待。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 流封装与文件系统
  2. gRPC 与 Protobuf 服务
  3. 内存管理与垃圾回收