fix: use pdf previews for documents
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,8 @@ class DocumentObserver
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
app(\App\Services\DocumentPdfPreviewService::class)->clearCachedPreview($document);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('清理文档文件失败', [
|
||||
'document_id' => $document->id,
|
||||
|
||||
190
app/Services/DocumentPdfPreviewService.php
Normal file
190
app/Services/DocumentPdfPreviewService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user