Реалізація генерації 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 і переконайтеся, що шрифт завантажено до генерації. Для офлайн-середовища вбудуйте шрифт локально.







