Реализация конвертации документов (PDF → Image, DOCX → PDF) на сервере
Два самых распространённых сценария конвертации на сервере: превратить страницы PDF в изображения (для предпросмотра, thumbnails) и сконвертировать DOCX в PDF (для скачивания в едином формате, печати, архивирования).
PDF → Image
Стандартный инструмент — Ghostscript (gs). Присутствует в большинстве Linux-дистрибутивов, хорошо справляется с векторной графикой и шрифтами:
apt install ghostscript
Конвертация первой страницы в JPEG:
gs -dNOPAUSE -dBATCH -sDEVICE=jpeg -r150 \
-dFirstPage=1 -dLastPage=1 \
-sOutputFile=page_%02d.jpg \
input.pdf
-r150 — DPI (150 достаточно для превью, 300 — для полного качества). -dFirstPage/-dLastPage — диапазон страниц.
Альтернатива — ImageMagick через Ghostscript под капотом:
convert -density 150 input.pdf[0] -quality 85 page_0.jpg
[0] — индекс страницы (0 = первая).
Важно: в новых версиях ImageMagick (7+) обработка PDF через Ghostscript по умолчанию отключена по соображениям безопасности. Нужно разрешить в /etc/ImageMagick-7/policy.xml:
<!-- было: rights="none" -->
<policy domain="coder" rights="read|write" pattern="PDF" />
DOCX → PDF
LibreOffice в headless-режиме — наиболее надёжный бесплатный инструмент:
apt install libreoffice
libreoffice --headless --convert-to pdf --outdir /output /input/document.docx
Конвертация занимает 2–8 секунд на типичный документ. LibreOffice при первом запуске создаёт профиль пользователя — при параллельных вызовах из разных процессов возникает конфликт. Решение: уникальный профиль для каждого вызова через --env:UserInstallation:
libreoffice --headless \
"--env:UserInstallation=file:///tmp/lo_profile_$$" \
--convert-to pdf \
--outdir /tmp/output \
/tmp/input/document.docx
$$ — PID процесса, обеспечивает уникальность.
PHP-сервис конвертации
namespace App\Services;
use Illuminate\Support\Str;
class DocumentConversionService
{
public function pdfToImages(
string $pdfPath,
string $outputDir,
int $dpi = 150,
string $format = 'jpg',
?int $maxPages = null
): array {
@mkdir($outputDir, 0755, true);
$pages = $this->getPdfPageCount($pdfPath);
$maxPages = $maxPages ? min($maxPages, $pages) : $pages;
$outputPattern = escapeshellarg("{$outputDir}/page_%03d.{$format}");
$cmd = implode(' ', [
'gs',
'-dNOPAUSE -dBATCH -dSAFER',
'-sDEVICE=' . ($format === 'png' ? 'png16m' : 'jpeg'),
"-r{$dpi}",
"-dFirstPage=1 -dLastPage={$maxPages}",
'-dJPEGQ=85',
"-sOutputFile={$outputPattern}",
escapeshellarg($pdfPath),
'2>&1',
]);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new \RuntimeException(
"PDF conversion failed: " . implode("\n", $output)
);
}
// Собираем список созданных файлов
$files = [];
for ($i = 1; $i <= $maxPages; $i++) {
$file = sprintf("{$outputDir}/page_%03d.{$format}", $i);
if (file_exists($file)) {
$files[] = $file;
}
}
return $files;
}
public function docxToPdf(string $docxPath, string $outputDir): string
{
@mkdir($outputDir, 0755, true);
$profileDir = sys_get_temp_dir() . '/lo_profile_' . Str::random(8);
@mkdir($profileDir, 0755, true);
$cmd = implode(' ', [
'libreoffice',
'--headless',
"--env:UserInstallation=" . escapeshellarg("file://{$profileDir}"),
'--convert-to pdf',
'--outdir ' . escapeshellarg($outputDir),
escapeshellarg($docxPath),
'2>&1',
]);
exec($cmd, $output, $exitCode);
// Удаляем временный профиль
$this->rmdir($profileDir);
if ($exitCode !== 0) {
throw new \RuntimeException(
"DOCX to PDF failed: " . implode("\n", $output)
);
}
$outputFile = $outputDir . '/' . pathinfo($docxPath, PATHINFO_FILENAME) . '.pdf';
if (!file_exists($outputFile)) {
throw new \RuntimeException("Output PDF not found after conversion");
}
return $outputFile;
}
public function getPdfPageCount(string $path): int
{
$cmd = "pdfinfo " . escapeshellarg($path) . " 2>/dev/null | grep 'Pages:' | awk '{print $2}'";
$output = shell_exec($cmd);
return max(1, (int) trim($output ?? '1'));
}
private function rmdir(string $dir): void
{
if (!is_dir($dir)) return;
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getRealPath()) : unlink($item->getRealPath());
}
rmdir($dir);
}
}
Jobs для фоновой обработки
// app/Jobs/ConvertDocumentJob.php
class ConvertDocumentJob implements ShouldQueue
{
public int $timeout = 300;
public int $tries = 2;
public function __construct(
private int $documentId,
private string $type // 'pdf_to_images' | 'docx_to_pdf'
) {}
public function handle(DocumentConversionService $converter): void
{
$doc = Document::findOrFail($this->documentId);
$src = Storage::disk('documents')->path($doc->path);
$outDir = Storage::disk('documents')->path("converted/{$doc->id}");
match ($this->type) {
'pdf_to_images' => $this->convertPdfToImages($converter, $doc, $src, $outDir),
'docx_to_pdf' => $this->convertDocxToPdf($converter, $doc, $src, $outDir),
};
}
private function convertPdfToImages(
DocumentConversionService $converter,
Document $doc,
string $src,
string $outDir
): void {
$files = $converter->pdfToImages($src, $outDir, dpi: 150, maxPages: 20);
$pages = array_map(
fn($f) => "converted/{$doc->id}/" . basename($f),
$files
);
$doc->update([
'preview_pages' => $pages,
'page_count' => count($pages),
'status' => 'ready',
]);
}
private function convertDocxToPdf(
DocumentConversionService $converter,
Document $doc,
string $src,
string $outDir
): void {
$pdfPath = $converter->docxToPdf($src, $outDir);
$relPath = "converted/{$doc->id}/" . basename($pdfPath);
$doc->update([
'pdf_path' => $relPath,
'status' => 'ready',
]);
}
}
Безопасность при обработке документов
Пользовательские файлы — потенциальный вектор атаки. Обязательные меры:
- Проверять MIME-тип через
finfo_file(), а не только расширение. - Запускать конвертацию в отдельном пользователе без прав на запись в корень проекта.
- Ограничивать размер файла (
max: 50000в правилах валидации — 50 МБ). - Не хранить загруженные файлы в публично доступном пути до завершения проверки.
$request->validate([
'file' => [
'required',
'file',
'max:51200', // 50 MB
'mimes:pdf,docx,doc',
],
]);
// Дополнительная проверка реального MIME
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($request->file('file')->getRealPath());
abort_unless(in_array($mime, ['application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']), 422);
Сроки
Настройка Ghostscript, LibreOffice, сервис конвертации, Jobs — 6–8 часов. Добавление превью страниц PDF в интерфейс, endpoint скачивания сконвертированного PDF — ещё 3–4 часа.







