Реалізація генерації PDF
Зауважимо: коли рахунок йде із затримкою через ручне формування PDF — бізнес втрачає гроші. В одному проекті генерація 500 рахунків на день займала 4 години ручної праці. Ми автоматизували процес: тепер PDF формуються за 5 хвилин після замовлення, а клієнти отримують їх одразу. Розкажу, як ми впроваджуємо серверну генерацію PDF з використанням HTML-to-PDF. Наш клієнт — сервіс онлайн-бухгалтерії — автоматизував випуск актів виконаних робіт.
Проблеми, які вирішуємо
Складний макет із CSS. Багато бібліотек не розуміють Grid, Flexbox, змінні. Рішення — headless Chrome через Browsershot (PHP) або Puppeteer (Node.js). Вони рендерять HTML як браузер — ідеально для рахунків, договорів, звітів.
Шрифти та кирилиця. Без правильного налаштування символи перетворюються на кракозябри. Підключаємо Google Fonts через @import або вбудовуємо локальні шрифти. У TCPDF використовуємо DejaVu Sans — він підтримує кирилицю без проблем.
Продуктивність. Генерація одного PDF через браузер займає 2-5 секунд. При 5000 документах на годину — це критично. Рішення: асинхронна черга (Laravel Queue / Bull) та збереження в S3. Користувач отримує миттєву відповідь, а PDF генерується у фоні.
Як ми це робимо: стек та кейс
Для клієнта — сервісу онлайн-бухгалтерії — впровадили генерацію актів виконаних робіт на Laravel 11. Використовували spatie/browsershot (на базі Puppeteer). Шаблон написаний на Blade із CSS Grid для таблиці послуг.
Laravel: Browsershot (Puppeteer)
Browsershot використовує headless Chrome для рендерингу HTML у PDF — підтримує CSS Grid, Flexbox, змінні, шрифти.
use Spatie\Browsershot\Browsershot;
class InvoicePdfService
{
public function generate(Invoice $invoice): string
{
$html = view('pdf.invoice', ['invoice' => $invoice])->render();
$path = storage_path("app/invoices/invoice-{$invoice->id}.pdf");
Browsershot::html($html)
->format('A4')
->margins(15, 15, 15, 15) // мм
->showBackground()
->emulateMedia('print')
->waitUntilNetworkIdle() // дочекатись завантаження шрифтів
->save($path);
return $path;
}
}
// Controller
public function download(Invoice $invoice): Response
{
$path = $this->invoicePdfService->generate($invoice);
return response()->download(
$path,
"invoice-{$invoice->number}.pdf",
['Content-Type' => 'application/pdf']
);
}
<!-- resources/views/pdf/invoice.blade.php -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; font-size: 12px; color: #1a1a1a; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.invoice-number { font-size: 24px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #f3f4f6; padding: 8px; text-align: left; font-weight: 600; }
td { padding: 8px; border-bottom: 1px solid #e5e7eb; }
.total { font-size: 16px; font-weight: 700; text-align: right; margin-top: 20px; }
@media print {
.page-break { page-break-after: always; }
}
</style>
</head>
<body>
<div class="header">
<div>
<img src="{{ public_path('logo.png') }}" height="40">
<div>{{ $invoice->company->name }}</div>
</div>
<div>
<div class="invoice-number">Счёт #{{ $invoice->number }}</div>
<div>Дата: {{ $invoice->date->format('d.m.Y') }}</div>
</div>
</div>
<table>
<thead>
<tr><th>Описание</th><th>Кол-во</th><th>Цена</th><th>Сумма</th></tr>
</thead>
<tbody>
@foreach($invoice->items as $item)
<tr>
<td>{{ $item->description }}</td>
<td>{{ $item->quantity }}</td>
<td>{{ number_format($item->price, 2) }} ?</td>
<td>{{ number_format($item->total, 2) }} ?</td>
</tr>
@endforeach
</tbody>
</table>
<div class="total">Итого: {{ number_format($invoice->total, 2) }} ?</div>
</body>
</html>
Node.js: Puppeteer
import puppeteer from 'puppeteer';
import Handlebars from 'handlebars';
async function generateInvoicePdf(invoice: Invoice): Promise<Buffer> {
const templateSource = await fs.readFile('./templates/invoice.html', 'utf-8');
const template = Handlebars.compile(templateSource);
const html = template(invoice);
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
return await page.pdf({
format: 'A4',
margin: { top: '15mm', right: '15mm', bottom: '15mm', left: '15mm' },
printBackground: true,
});
} finally {
await browser.close();
}
}
TCPDF: PHP-нативна генерація (без браузера)
Підходить для простих документів без складного CSS:
use TCPDF;
class ContractPdfService
{
public function generate(Contract $contract): string
{
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8');
$pdf->SetCreator('MyApp');
$pdf->SetAuthor($contract->company->name);
$pdf->SetTitle('Договір №' . $contract->number);
$pdf->SetFont('dejavusans', '', 10);
$pdf->AddPage();
$html = view('pdf.contract-simple', compact('contract'))->render();
$pdf->writeHTML($html, true, false, true, false, '');
$path = storage_path("app/contracts/contract-{$contract->id}.pdf");
$pdf->Output($path, 'F');
return $path;
}
}
Асинхронна генерація в черзі
class GenerateInvoicePdfJob implements ShouldQueue
{
public int $timeout = 120;
public function __construct(private Invoice $invoice) {}
public function handle(InvoicePdfService $service): void
{
$path = $service->generate($this->invoice);
// Завантажити в S3
$s3Key = "invoices/{$this->invoice->user_id}/{$this->invoice->id}.pdf";
Storage::disk('s3')->put($s3Key, file_get_contents($path));
$this->invoice->update(['pdf_key' => $s3Key, 'pdf_generated_at' => now()]);
unlink($path);
// Повідомити користувача
$this->invoice->user->notify(new InvoiceReadyNotification($this->invoice));
}
}
Як вибрати між Browsershot і TCPDF?
| Критерій | Browsershot (Laravel) | Puppeteer (Node.js) | TCPDF (PHP) |
|---|---|---|---|
| CSS Grid/Flexbox | Підтримує | Підтримує | Ні |
| Швидкість рендерингу | 2-5 сек | 2-5 сек | <1 сек |
| Кирилиця | Через Google Fonts | Через Google Fonts | Вбудований DejaVu |
| Складність налаштування | Середня | Середня | Низька |
| Використання пам'яті | ~200 МБ | ~200 МБ | ~50 МБ |
Browsershot в 2 рази швидший за TCPDF для складних макетів, але для простих таблиць TCPDF вигідніший по пам'яті.
Процес роботи
- Аналітика. Визначаємо типи документів (рахунки, договори, звіти). Виявляємо складність верстки.
- Проектування. Створюємо шаблон на Blade або Handlebars. Налаштовуємо шрифти.
- Реалізація. Пишемо сервіс генерації, підключаємо чергу для асинхронної обробки.
- Тестування. Перевіряємо на 50+ документах. Порівнюємо розмір та якість.
- Деплой. Налаштовуємо S3, CDN, моніторинг (лог помилок генерації).
Терміни реалізації
- Базова генерація (Browsershot/Puppeteer для одного шаблону): від 2 до 3 днів.
- З асинхронною чергою та S3: від 3 до 4 днів.
- З електронним підписом: додається 2 дні.
Що входить у роботу
- Розробка шаблону PDF з урахуванням корпоративного стилю.
- Налаштування черги та зберігання в хмарі (S3, MinIO).
- Документація з API генерації.
- Передача доступів до сервера та репозиторію.
- Навчання співробітника запуску та моніторингу.
- Пост-релізна підтримка 2 тижні.
Чому варто довірити це завдання нам?
У нас 10+ років досвіду у веб-розробці та понад 50 проектів із генерацією PDF. Використовуємо лише перевірені зв'язки: Laravel + Browsershot, Node.js + Puppeteer. Гарантуємо стабільність: код покритий тестами, черга перезапускається при збоях. Отримайте консультацію по вашому проекту — оцінимо його за один день.
Чек-лист: типові помилки при генерації PDF
| Помилка | Причина | Рішення |
|---|---|---|
| Елементи вилазять за межі | Відсутність @media print | Додати медіа-запит з page-break |
| Текст прилипає до країв | Не задані margin | Встановити відступи (15 мм) |
| Кракозябри замість кирилиці | Неправильні шрифти | Використовувати DejaVu або Google Fonts |
| Розмір PDF > 10 МБ | Великі зображення base64 | Оптимізувати, використовувати зовнішні посилання |
| Помилка рендерингу | Незакриті теги HTML | Валідувати HTML перед генерацією |
Встановіть ліміт на кількість сторінок та розмір файлу — це врятує від зависання генерації. Для більш детальної інформації зверніться до офіційної документації Puppeteer.
Налаштування шрифтів для стабільної кирилиці
У TCPDF використовуйте шрифт dejavusans — він уже вбудований і підтримує кирилицю. У Browsershot підключіть Google Fonts через @import і переконайтеся, що шрифт завантажено до генерації. Для офлайн-середовища вбудуйте шрифт локально.







