背景
最近做了一个文档安全预览的项目——用户上传 PDF,系统把 PDF 转成图片在线展示,不给原始文件,防止下载和传播。
听起来简单,但实际做下来踩了不少坑。今天把 PDF 转图片 这块的完整实现方案分享出来,包括异步处理、进度反馈、云端存储,以及那些容易忽略的细节。
整体思路
先说结论,整个流程是这样的:
用户上传 PDF
↓
服务端接收文件,保存到临时目录
↓
用 Imagick 打开 PDF,验证合法性,获取总页数
↓
创建数据库记录(状态 = pending)
↓
返回成功响应给前端(不等转换完成)
↓
后台定时任务扫描 pending 记录,启动转换
↓
逐页渲染为 PNG → 上传到 OSS → 删除本地临时文件
↓
更新数据库状态为 ready
↓
前端轮询感知状态变化,展示预览入口
关键点:转换是异步的,不阻塞用户上传。用户提交完文件就可以去干别的事,前端通过轮询知道什么时候转换好了。
为什么选 Imagick?
PHP 生态里处理 PDF 的选项不多:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Imagick(ImageMagick 的 PHP 扩展) | 直接读 PDF 逐页渲染,质量好,API 简单 | 依赖 ImageMagick + Ghostscript |
| Ghostscript 命令行 | 不依赖 PHP 扩展 | 需要自己拼命令、处理输出文件 |
| pdftoppm(poppler-utils) | 速度快 | 同样需要命令行调用 |
| PDF.js(前端) | 不需要服务端转换 | 需要原始 PDF 文件,不适合安全预览场景 |
选 Imagick 的原因:
- 逐页渲染方便——
readImage("file.pdf[0]")读第一页,readImage("file.pdf[1]")读第二页,循环就行 - 可以直接操作图片参数——分辨率、背景色、输出格式、压缩质量,都在 PHP 里控制
- 和后续的 OSS 上传衔接好——渲染完直接写本地临时文件,上传后删除,流程清晰
第一步:上传时验证 PDF
不是所有用户上传的文件都是真正的 PDF。有人可能改了扩展名,有人可能上传了损坏的文件。所以在创建数据库记录之前,先用 Imagick 实际打开文件验证一下:
// UploadController.php
$tempFile = $tempDir . '/' . uniqid('pdf_') . '.pdf';
move_uploaded_file($file['tmp_name'], $tempFile);
try {
$imagick = new \Imagick();
$imagick->readImage($tempFile);
$pageCount = $imagick->getNumberImages();
$imagick->clear();
$imagick->destroy();
if ($pageCount > 200) {
unlink($tempFile);
return Response::json(['code' => 1, 'message' => 'PDF页数不能超过200页']);
}
} catch (\Exception $e) {
unlink($tempFile);
return Response::json(['code' => 1, 'message' => '无效的PDF文件']);
}
几个要点:
- 不信任 MIME 类型——
$_FILES['pdf']['type']可以是任何东西,必须用 Imagick 实际打开验证 - 页数限制——200 页是上限,防止超大 PDF 耗尽服务器资源
- 文件大小限制——50MB,在验证之前就已经检查过了
- 验证完就释放资源——
clear()+destroy()必须调用,否则 Imagick 对象会一直占着内存
验证通过后,创建数据库记录,把临时文件路径存进去:
$document = new Document();
$document->user_id = $user['id'];
$document->name = pathinfo($clientFilename, PATHINFO_FILENAME);
$document->page_count = $pageCount;
$document->status = 'pending';
$document->temp_pdf_path = $tempFile;
$document->save();
注意这里状态是 pending,不是 converting。因为转换还没开始——上传接口要尽快返回响应,不能让用户等着。
第二步:异步转换,不阻塞请求
这是整个方案的核心设计决策:转换在独立进程中执行,不占用 PHP-FPM worker。
为什么不用 exec() 后台运行?
最初的设计是用 exec() 直接起一个后台进程:
$cmd = sprintf(
'php %s/app/commands/ConvertPdf.php %d %s > /dev/null 2>&1 &',
escapeshellarg($projectRoot),
$document->id,
escapeshellarg($tempFile)
);
exec($cmd);
这个方案能用,但有几个问题:
- 并发控制难——如果用户连续上传 10 个文件,就会同时启动 10 个转换进程,服务器可能扛不住
- 错误处理弱——后台进程挂了,你很难知道为什么
- 资源竞争——多个进程同时操作 Imagick,内存占用不可控
改用定时任务扫描队列
最终方案是队列模式:上传接口只负责把文件存好、记录写进数据库,转换交给定时任务(cron)来扫:
// ProcessQueue.php — 由 cron 每分钟执行一次
$pendingDocs = Document::where('status', 'pending')->get();
foreach ($pendingDocs as $document) {
// 逐个处理,天然串行
$success = $converter->convert($document, $document->temp_pdf_path);
if ($success) {
// 清理临时文件
unlink($document->temp_pdf_path);
}
}
好处:
- 天然串行——一个处理完再处理下一个,不会把服务器打满
- 可重试——失败的记录状态是
failed,可以手动或自动重新放入队列 - 可监控——cron 任务的输出可以写到日志,出问题直接看
第三步:逐页渲染 + 上传 OSS
转换的核心逻辑在 PdfConverterService 里:
// PdfConverterService.php
public function convert(Document $document, string $pdfPath): bool
{
$tempDir = dirname(__DIR__, 2) . '/storage/temp/convert_' . $document->id;
mkdir($tempDir, 0755, true);
try {
$document->status = 'converting';
$document->save();
$totalPages = $document->page_count;
for ($index = 0; $index < $totalPages; $index++) {
$pageNum = $index + 1;
// 每页单独创建一个 Imagick 对象,处理完立即释放
$imagick = new \Imagick();
$imagick->setResolution(150, 150);
$imagick->setBackgroundColor('white');
$imagick->readImage("{$pdfPath}[{$index}]");
// 处理透明背景:把 alpha 通道转为白色背景
if ($imagick->getImageAlphaChannel()) {
$imagick->setImageBackgroundColor('white');
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_BACKGROUND);
}
// 输出为 PNG(后面加水印时质量更好)
$pagePath = $tempDir . '/' . $pageNum . '.png';
$imagick->setImageFormat('png');
$imagick->writeImage($pagePath);
// 上传到 OSS
$ossPath = $document->ossPagesPath() . '/' . $pageNum . '.png';
$this->oss->uploadFile($ossPath, $pagePath);
// 立即删除本地临时文件,节省磁盘
unlink($pagePath);
// 释放 Imagick 资源
$imagick->clear();
$imagick->destroy();
// 更新进度:前端轮询时可以看到"已转换 X/Y 页"
$document->pages_converted = $pageNum;
$document->save();
}
$document->status = 'ready';
$document->save();
// 清理临时目录
array_map('unlink', glob($tempDir . '/*'));
rmdir($tempDir);
return true;
} catch (\Exception $e) {
$document->status = 'failed';
$document->save();
// 清理...
return false;
}
}
几个关键细节
1. 分辨率设为 150 DPI
这是清晰度和性能的平衡点:
- 72 DPI:文件小,但文字模糊,放大后看不清
- 150 DPI:屏幕上看着清晰,单页文件大小适中(几百 KB)
- 300 DPI:印刷级质量,但文件太大,加载慢,OSS 存储成本高
对于在线预览场景,150 DPI 完全够用。
2. 背景色设为白色
PDF 页面可能是透明的(尤其是扫描件或设计稿导出的 PDF)。如果不设背景色,渲染出来的 PNG 会有透明区域,显示为黑色或棋盘格。
$imagick->setBackgroundColor('white');
对于已经有 alpha 通道的图片,还需要额外处理:
if ($imagick->getImageAlphaChannel()) {
$imagick->setImageBackgroundColor('white');
$imagick->setImageAlphaChannel(\Imagick::ALPHACHANNEL_BACKGROUND);
}
这一步把透明区域”压”成白色背景。
3. 输出 PNG 而不是 JPEG
虽然 PNG 文件比 JPEG 大,但这里选 PNG 有两个原因:
- 后续要加水印——水印合成用 Imagick 的
compositeImage,PNG 支持 alpha 通道,合成效果更好 - 避免 JPEG 反复压缩的质量损失——水印合成后还要再保存一次,JPEG 会二次压缩
4. 每页单独创建 Imagick 对象
不要用一个 Imagick 对象读整个 PDF 然后遍历页面。原因是:
- 大 PDF(比如 100 页)会一次性把所有页面加载到内存
- 逐页创建、处理、释放,内存占用始终只有一页的大小
for ($index = 0; $index < $totalPages; $index++) {
$imagick = new \Imagick();
$imagick->readImage("{$pdfPath}[{$index}]");
// 处理...
$imagick->clear();
$imagick->destroy();
}
5. 每处理完一页就更新进度
$document->pages_converted = $pageNum;
$document->save();
这样前端轮询时可以看到”正在转换… (15/50 页)”,用户不用干等着不知道进度。
第四步:前端轮询,实时反馈进度
后端在转换,前端不能傻等。用 fetch + setTimeout 做轮询:
// upload.js
async pollStatus() {
const poll = async () => {
if (!this.documentId) return;
const res = await fetch(`/api/upload/status/${this.documentId}`);
const data = await res.json();
const status = data.data.status;
const pageCount = data.data.page_count || 0;
const pagesConverted = data.data.pages_converted || 0;
if (status === 'ready') {
this.progress = 100;
this.statusText = '转换完成!';
this.uploading = false;
setTimeout(() => window.location.href = '/dashboard', 1000);
} else if (status === 'failed') {
this.error = '转换失败,请重试';
this.uploading = false;
} else if (status === 'converting' && pageCount > 0) {
// 根据已转换页数计算进度
const convertProgress = Math.round((pagesConverted / pageCount) * 65);
this.progress = 35 + convertProgress;
this.statusText = `正在转换... (${pagesConverted}/${pageCount} 页)`;
setTimeout(poll, 1500);
} else {
// 还在排队等待
this.progress = Math.min(this.progress + 2, 35);
this.statusText = '等待转换...';
setTimeout(poll, 2000);
}
};
setTimeout(poll, 2000);
}
进度条的设计:
- 上传阶段:0% → 30%
- 等待转换:30% → 35%(缓慢增长,表示在排队)
- 转换中:35% → 100%(根据
pages_converted / page_count精确计算) - 完成:跳转到文档列表
轮询间隔:转换中 1.5 秒一次,等待中 2 秒一次。不要太频繁,避免给服务器造成不必要的压力。
也可以用 htmx 来做轮询,更简洁:
<div hx-get="/api/upload/status-html/{{ doc.id }}"
hx-trigger="every 2s"
hx-target="closest .bg-white"
hx-swap="outerHTML">
</div>
服务端返回一段 HTML 片段,htmx 自动替换整个卡片。这样连 JS 都不用写。
第五步:存储结构
转换后的图片存在阿里云 OSS 上,目录结构按用户和文档组织:
viewshield/ ← OSS Bucket 前缀
└── documents/
└── {user_id}/
└── {document_id}/
└── pages/
├── 1.png
├── 2.png
└── ...
└── watermarked/ ← 带水印的版本(后续生成)
└── {viewer_id}/
├── 1.png
├── 2.png
└── ...
几个设计考虑:
- 原始 PDF 不保留——转换完成后删除,系统里只有图片,从源头杜绝文件泄露
- 按用户/文档分目录——方便管理和清理,删除文档时直接删整个目录
- 水印版本按查看人分目录——每个查看人看到的水印不同(印着他的名字),缓存起来复用,不用每次重新生成
容易踩的坑
1. Imagick 内存泄漏
Imagick 对象非常吃内存。如果不及时释放,处理几个大 PDF 之后 PHP 进程就会 OOM。
解决方案:每页处理完立即调用 clear() + destroy(),不要复用对象。
2. Ghostscript 版本问题
Imagick 依赖 Ghostscript 来解析 PDF。如果服务器上的 Ghostscript 版本太旧,某些 PDF 会渲染失败或输出空白页。
解决方案:确保安装较新版本的 Ghostscript(建议 9.50+)。Ubuntu 上 apt install ghostscript,CentOS 上可能需要编译安装。
3. 中文 PDF 字体缺失
如果 PDF 里嵌入了中文字体,但服务器上缺少对应字体,Imagick 渲染时中文会变成方块或乱码。
解决方案:安装中文字体包。
# Ubuntu/Debian
apt install fonts-wqy-zenhei fonts-wqy-microhei
# CentOS
yum install wqy-zenhei-fonts wqy-microhei-fonts
4. 临时文件清理
转换过程中会产生大量临时文件(原始 PDF 的副本、逐页渲染的 PNG)。如果转换中途失败,这些文件可能残留。
解决方案:在 try/catch 的 finally 逻辑里统一清理,或者用 register_shutdown_function 确保清理。
5. 并发转换的资源竞争
如果多个文档同时转换,Imagick + Ghostscript 的 CPU 和内存占用会叠加。
解决方案:用队列模式串行处理(如前文所述),或者限制同时运行的转换进程数。
6. OSS 上传失败
网络波动可能导致某一页上传 OSS 失败。如果不处理,这一页就丢了,用户预览时会看到空白。
解决方案:OssService 里做了异常捕获和日志记录。更完善的方案是加上传重试(比如重试 3 次)。
性能参考
以一台 2 核 4G 的云服务器为例:
| PDF 规模 | 转换耗时 | 内存峰值 | OSS 存储 |
|---|---|---|---|
| 10 页 / 2MB | ~5 秒 | ~150MB | ~8MB |
| 50 页 / 15MB | ~25 秒 | ~180MB | ~40MB |
| 100 页 / 30MB | ~50 秒 | ~200MB | ~80MB |
| 200 页 / 50MB | ~100 秒 | ~220MB | ~160MB |
逐页处理的好处是内存峰值基本稳定(只跟单页大小有关),不会随页数线性增长。
完整代码回顾
最后把整个流程的关键代码串一遍:
上传接口(接收文件 → 验证 → 存临时文件 → 写数据库 → 立即返回)
// 1. 验证文件
$imagick = new \Imagick();
$imagick->readImage($tempFile);
$pageCount = $imagick->getNumberImages();
$imagick->clear();
$imagick->destroy();
// 2. 写数据库(状态 = pending)
$document = new Document();
$document->status = 'pending';
$document->temp_pdf_path = $tempFile;
$document->save();
// 3. 立即返回,不等转换
return Response::json(['code' => 0, 'data' => ['document_id' => $document->id]]);
定时任务(扫描 pending → 逐个转换)
$pendingDocs = Document::where('status', 'pending')->get();
foreach ($pendingDocs as $document) {
$converter->convert($document, $document->temp_pdf_path);
unlink($document->temp_pdf_path); // 清理原始 PDF
}
转换服务(逐页渲染 → 上传 OSS → 更新进度)
for ($index = 0; $index < $totalPages; $index++) {
$imagick = new \Imagick();
$imagick->setResolution(150, 150);
$imagick->setBackgroundColor('white');
$imagick->readImage("{$pdfPath}[{$index}]");
$imagick->setImageFormat('png');
$imagick->writeImage($tempDir . '/' . ($index + 1) . '.png');
$this->oss->uploadFile($ossPath, $pagePath);
unlink($pagePath);
$imagick->clear();
$imagick->destroy();
$document->pages_converted = $index + 1;
$document->save();
}
$document->status = 'ready';
$document->save();
前端轮询(每 1.5 秒查一次状态 → 更新进度条 → 完成后跳转)
const poll = async () => {
const data = await fetch(`/api/upload/status/${docId}`).then(r => r.json());
if (data.data.status === 'ready') {
window.location.href = '/dashboard';
} else if (data.data.status === 'converting') {
updateProgress(data.data.pages_converted, data.data.page_count);
setTimeout(poll, 1500);
}
};
总结
PDF 转图片这个需求本身不复杂,但要做到生产可用,需要考虑的事情不少:
- 异步处理——不能让转换阻塞用户请求
- 资源控制——Imagick 吃内存,必须逐页处理、及时释放
- 进度反馈——用户不能对着一个转圈的页面干等
- 错误处理——转换失败要能感知、能重试
- 文件清理——临时文件不能残留
- 存储设计——原始 PDF 要删除,图片按合理结构组织
这套方案在我们的项目里跑得很稳,处理了几百个 PDF 没出过问题。如果你也有类似的需求,可以直接参考。