Files
KnowledgeBase/app/Services/DocumentPdfPreviewService.php

191 lines
6.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class DocumentPdfPreviewService
{
public function canPreview(Document $document): bool
{
return $document->conversion_status === 'completed'
&& ! empty($document->file_path)
&& Storage::disk('local')->exists($document->file_path);
}
public function getPreviewPath(Document $document): string
{
if (! $this->canPreview($document)) {
throw new \RuntimeException('文档尚未完成转换或原文件不存在');
}
if ($this->isPdf($document)) {
return Storage::disk('local')->path($document->file_path);
}
$previewPath = $this->cachedPreviewPath($document);
if (! $this->cachedPreviewIsFresh($document, $previewPath)) {
$this->generatePdfPreview($document, $previewPath);
}
return Storage::disk('previews')->path($previewPath);
}
public function previewUrl(Document $document): string
{
return route('documents.preview-pdf', $document);
}
public function clearCachedPreview(Document $document): void
{
Storage::disk('previews')->deleteDirectory((string) $document->getKey());
}
protected function isPdf(Document $document): bool
{
$extension = strtolower(pathinfo($document->display_file_name ?: $document->file_path, PATHINFO_EXTENSION));
return $document->mime_type === 'application/pdf' || $extension === 'pdf';
}
protected function cachedPreviewPath(Document $document): string
{
return $document->getKey() . '/preview-libreoffice.pdf';
}
protected function cachedPreviewIsFresh(Document $document, string $previewPath): bool
{
if (! Storage::disk('previews')->exists($previewPath)) {
return false;
}
$sourceMtime = Storage::disk('local')->lastModified($document->file_path);
$previewMtime = Storage::disk('previews')->lastModified($previewPath);
return $previewMtime >= $sourceMtime;
}
protected function generatePdfPreview(Document $document, string $previewPath): void
{
$sourcePath = Storage::disk('local')->path($document->file_path);
$absolutePreviewPath = Storage::disk('previews')->path($previewPath);
$previewDirectory = dirname($absolutePreviewPath);
if (! is_dir($previewDirectory) && ! mkdir($previewDirectory, 0775, true) && ! is_dir($previewDirectory)) {
throw new \RuntimeException('无法创建 PDF 预览目录');
}
$this->convertWithLibreOffice($sourcePath, $absolutePreviewPath, $previewDirectory);
if (! file_exists($absolutePreviewPath) || filesize($absolutePreviewPath) === 0) {
throw new \RuntimeException('PDF 预览生成失败');
}
}
protected function convertWithLibreOffice(string $sourcePath, string $targetPath, string $workingDirectory): void
{
$binary = $this->resolveLibreOfficeBinary();
if ($binary === null) {
throw new \RuntimeException('无法生成准确的 PDF 预览:服务器未安装 LibreOffice/soffice。请安装 LibreOffice 和中文字体后重试。');
}
$tmpDirectory = $workingDirectory . '/tmp-' . bin2hex(random_bytes(8));
$profileDirectory = $tmpDirectory . '/profile';
if (! mkdir($profileDirectory, 0775, true) && ! is_dir($profileDirectory)) {
throw new \RuntimeException('无法创建 LibreOffice 临时目录');
}
$process = new Process([
$binary,
'--headless',
'--nologo',
'--nofirststartwizard',
'--norestore',
'-env:UserInstallation=file://' . $profileDirectory,
'--convert-to',
'pdf',
'--outdir',
$tmpDirectory,
$sourcePath,
]);
$process->setTimeout((int) config('documents.conversion.timeout', 300));
try {
$process->mustRun();
$convertedPath = $this->findConvertedPdf($tmpDirectory, $sourcePath);
if ($convertedPath === null) {
throw new \RuntimeException(trim($process->getOutput() . "\n" . $process->getErrorOutput()) ?: 'LibreOffice 未输出 PDF 文件');
}
if (! rename($convertedPath, $targetPath)) {
throw new \RuntimeException('无法保存 LibreOffice 生成的 PDF 预览');
}
} catch (ProcessFailedException $e) {
throw new \RuntimeException('LibreOffice 转换 PDF 失败:' . trim($e->getProcess()->getErrorOutput() ?: $e->getProcess()->getOutput()), 0, $e);
} finally {
$this->deleteDirectory($tmpDirectory);
}
}
protected function resolveLibreOfficeBinary(): ?string
{
$configured = env('LIBREOFFICE_BINARY');
$candidates = array_filter([
is_string($configured) && $configured !== '' ? $configured : null,
'/opt/homebrew/bin/soffice',
'/opt/homebrew/bin/libreoffice',
'/usr/bin/libreoffice',
'/usr/bin/soffice',
'/usr/local/bin/libreoffice',
'/usr/local/bin/soffice',
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
]);
foreach ($candidates as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}
return null;
}
protected function findConvertedPdf(string $directory, string $sourcePath): ?string
{
$expectedPath = $directory . '/' . pathinfo($sourcePath, PATHINFO_FILENAME) . '.pdf';
if (is_file($expectedPath)) {
return $expectedPath;
}
$pdfFiles = glob($directory . '/*.pdf') ?: [];
return $pdfFiles[0] ?? null;
}
protected function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
}