fix: use pdf previews for documents

This commit is contained in:
2026-05-19 08:44:35 +08:00
parent 7e5a6a3f39
commit 63f2827cc9
14 changed files with 399 additions and 345 deletions

View File

@@ -248,7 +248,7 @@ class DocumentResource extends Resource
->modalSubmitAction(false)
->modalCancelActionLabel('关闭'),
Tables\Actions\Action::make('preview')
->label('预览 Markdown')
->label('预览 PDF')
->icon('heroicon-o-eye')
->color('info')
->visible(fn(Document $record): bool => $record->conversion_status === 'completed')

View File

@@ -49,6 +49,7 @@ class EditDocument extends EditRecord
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
Storage::disk('markdown')->delete($this->record->markdown_path);
}
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($this->record);
$data['file_path'] = $currentFile;
$data['file_name'] = $data['file_name'] ?? basename($currentFile);

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Resources\DocumentResource\Pages;
use App\Filament\Resources\DocumentResource;
use App\Services\DocumentPreviewService;
use App\Services\DocumentService;
use Filament\Actions;
use Filament\Infolists\Components\Section;
@@ -56,7 +55,7 @@ class ViewDocument extends ViewRecord
}
}),
Actions\Action::make('preview')
->label('预览 Markdown')
->label('预览 PDF')
->icon('heroicon-o-eye')
->color('info')
->visible(fn (): bool => $this->record->conversion_status === 'completed')

View File

@@ -3,27 +3,25 @@
namespace App\Http\Controllers;
use App\Models\Document;
use App\Services\DocumentPdfPreviewService;
use App\Services\DocumentService;
use App\Services\MarkdownRenderService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
class DocumentController extends Controller
{
protected DocumentService $documentService;
protected MarkdownRenderService $markdownRenderService;
protected DocumentPdfPreviewService $pdfPreviewService;
public function __construct(
DocumentService $documentService,
MarkdownRenderService $markdownRenderService
DocumentPdfPreviewService $pdfPreviewService
) {
$this->documentService = $documentService;
$this->markdownRenderService = $markdownRenderService;
$this->pdfPreviewService = $pdfPreviewService;
}
/**
* 预览文档的 Markdown 内容(支持图片显示)
* 预览文档的 PDF 内容
* 需求11.1, 11.3, 11.4
*
* @param Document $document
@@ -31,42 +29,37 @@ class DocumentController extends Controller
*/
public function preview(Document $document)
{
// 验证用户权限(使用 DocumentPolicy
// 需求11.3
if (!Gate::allows('view', $document)) {
abort(403, '您没有权限预览此文档');
}
// 检查文档是否已完成转换
if ($document->conversion_status !== 'completed') {
return view('documents.preview', [
'document' => $document,
'markdownHtml' => null,
]);
return view('documents.preview', [
'document' => $document,
'canPreviewPdf' => $this->pdfPreviewService->canPreview($document),
'previewPdfUrl' => $this->pdfPreviewService->previewUrl($document),
]);
}
public function previewPdf(Document $document)
{
if (! Gate::allows('view', $document)) {
abort(403, '您没有权限预览此文档');
}
$markdownHtml = null;
try {
// 使用 DocumentPreviewService 的 Markdown 预览方法
// 这会修复图片路径并渲染 Markdown
// 需求11.1
$previewService = app(\App\Services\DocumentPreviewService::class);
$markdownHtml = $previewService->convertMarkdownToHtml($document);
} catch (\Exception $e) {
// 记录错误但不中断流程
\Log::error('Markdown 预览失败', [
$path = $this->pdfPreviewService->getPreviewPath($document);
} catch (\Throwable $e) {
\Log::error('PDF 预览生成失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
]);
abort(500, 'PDF 预览生成失败:' . $e->getMessage());
}
// 处理内容为空的情况
// 需求11.4
// 返回渲染后的 HTML 视图
return view('documents.preview', [
'document' => $document,
'markdownHtml' => $markdownHtml,
return response()->file($path, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="document-' . $document->getKey() . '.pdf"',
]);
}

View File

@@ -124,6 +124,8 @@ class DocumentObserver
]);
}
}
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
} catch (\Exception $e) {
\Log::error('清理文档文件失败', [
'document_id' => $document->id,

View File

@@ -0,0 +1,190 @@
<?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);
}
}