Реализация генерации PDF
Отметим: когда счёт уходит с задержкой из-за ручного формирования PDF — бизнес теряет деньги. В одном проекте генерация 500 счетов в день занимала 4 часа ручного труда. Мы автоматизировали процесс: теперь PDF формируются за 5 минут после заказа, а клиенты получают их сразу. Расскажу, как мы внедряем серверную генерацию PDF с использованием HTML-to-PDF. Наш клиент — сервис онлайн-бухгалтерии — сэкономил 1.2 млн рублей в год, автоматизировав выпуск актов выполненных работ. Ручная генерация стоила компании 300 000 рублей ежемесячно.
Проблемы, которые решаем
Сложный макет с 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 и убедитесь, что шрифт загружен до генерации. Для офлайн-среды встройте шрифт локально.







