diff --git a/Dockerfile b/Dockerfile index cfd8bfd..f09ca3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,10 @@ RUN apk add --no-cache \ oniguruma-dev \ # Pandoc文档转换工具 pandoc \ + # LibreOffice用于生成高保真PDF预览,Noto CJK用于中文字体渲染 + libreoffice \ + font-noto-cjk \ + ttf-dejavu \ # Node.js和npm (使用较小的版本) nodejs \ npm \ @@ -131,4 +135,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD /usr/local/bin/swoole-health-check.sh || exit 1 # 使用supervisor启动多个服务 -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] \ No newline at end of file +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/app/Filament/Resources/DocumentResource.php b/app/Filament/Resources/DocumentResource.php index 364c731..f435f17 100644 --- a/app/Filament/Resources/DocumentResource.php +++ b/app/Filament/Resources/DocumentResource.php @@ -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') diff --git a/app/Filament/Resources/DocumentResource/Pages/EditDocument.php b/app/Filament/Resources/DocumentResource/Pages/EditDocument.php index 5f0c6e9..38cbe97 100644 --- a/app/Filament/Resources/DocumentResource/Pages/EditDocument.php +++ b/app/Filament/Resources/DocumentResource/Pages/EditDocument.php @@ -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); diff --git a/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php b/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php index e13f485..96e8e67 100644 --- a/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php +++ b/app/Filament/Resources/DocumentResource/Pages/ViewDocument.php @@ -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') diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index 38c9f2f..316bd9c 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -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"', ]); } diff --git a/app/Observers/DocumentObserver.php b/app/Observers/DocumentObserver.php index 84890b0..52715cd 100644 --- a/app/Observers/DocumentObserver.php +++ b/app/Observers/DocumentObserver.php @@ -124,6 +124,8 @@ class DocumentObserver ]); } } + + app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document); } catch (\Exception $e) { \Log::error('清理文档文件失败', [ 'document_id' => $document->id, diff --git a/app/Services/DocumentPdfPreviewService.php b/app/Services/DocumentPdfPreviewService.php new file mode 100644 index 0000000..6515def --- /dev/null +++ b/app/Services/DocumentPdfPreviewService.php @@ -0,0 +1,190 @@ +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); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 2523623..97d94cb 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -63,6 +63,14 @@ return [ 'report' => false, ], + 'previews' => [ + 'driver' => 'local', + 'root' => storage_path('app/private/previews'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/resources/views/documents/preview.blade.php b/resources/views/documents/preview.blade.php index fb8e9f0..69a651e 100644 --- a/resources/views/documents/preview.blade.php +++ b/resources/views/documents/preview.blade.php @@ -5,162 +5,65 @@ -
{{ $document->description }}
+{{ $document->description }}
@endif预览失败
-{{ $error }}
+该文档尚未完成转换或原文件不存在
- 提示:这是文档的预览版本,可能与原始格式略有差异。如需查看完整格式,请下载文档。 -
-正在加载文档预览...
-文档等待转换中...
预览加载失败
-{{ $error }}
+该文档尚未完成转换或原文件不存在