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); } }