191 lines
6.4 KiB
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);
|
|
}
|
|
}
|