PHP 实战:如何把 PDF 转成图片实现在线安全预览

背景

最近做了一个文档安全预览的项目——用户上传 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 的原因:

  1. 逐页渲染方便——readImage("file.pdf[0]") 读第一页,readImage("file.pdf[1]") 读第二页,循环就行
  2. 可以直接操作图片参数——分辨率、背景色、输出格式、压缩质量,都在 PHP 里控制
  3. 和后续的 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);

这个方案能用,但有几个问题:

  1. 并发控制难——如果用户连续上传 10 个文件,就会同时启动 10 个转换进程,服务器可能扛不住
  2. 错误处理弱——后台进程挂了,你很难知道为什么
  3. 资源竞争——多个进程同时操作 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 转图片这个需求本身不复杂,但要做到生产可用,需要考虑的事情不少:

  1. 异步处理——不能让转换阻塞用户请求
  2. 资源控制——Imagick 吃内存,必须逐页处理、及时释放
  3. 进度反馈——用户不能对着一个转圈的页面干等
  4. 错误处理——转换失败要能感知、能重试
  5. 文件清理——临时文件不能残留
  6. 存储设计——原始 PDF 要删除,图片按合理结构组织

这套方案在我们的项目里跑得很稳,处理了几百个 PDF 没出过问题。如果你也有类似的需求,可以直接参考。

0

发表回复