Реалізація конвертації документів (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 години.







