Введение — Зачем нам вообще PDF?
Представьте ситуацию: пятница, вечер, до релиза осталось два дня. Заказчик внезапно вспоминает, что «было бы неплохо генерировать договоры в PDF». Знакомо?
Я оказался в похожей ситуации год назад. Задача казалась тривиальной: взять HTML-шаблон счёта, подставить данные и получить красивый PDF. «Часа на два работы», — подумал я. Как ошибался...
Скрытый текст
Почему вообще нужна генерация PDF? Типичные сценарии:
Счета, квитанции, договоры — всё, что нужно распечатать или отправить клиенту
Отчёты с графиками и таблицами
«Скриншоты» страниц для архивирования
Сертификаты, дипломы, бейджи
Почему HTML — отличный промежуточный формат? Мы уже знаем HTML и CSS. Есть мощные шаблонизаторы вроде Thymeleaf. Дизайнеры могут править шаблоны без Java-разработчика. И самое главное — можно увидеть результат в браузере до конвертации.
Какие альтернативы существуют?
Flying Saucer — старожил рынка, LGPL-лицензия (можно в коммерческих проектах), но ограниченная поддержка CSS
OpenPDF — форк старого iText 4.x, тоже LGPL, но не умеет конвертировать HTML из коробки
wkhtmltopdf — идеальный рендеринг CSS3 и JavaScript, но требует установки бинарника
Apache PDFBox — низкоуровневая библиотека для работы с PDF, без поддержки HTML
iText8 + html2pdf — мощный комбайн с хорошей CSS-поддержкой и активной разработкой
Я выбрал iText8, потому что он активно развивается, имеет отличную документацию и поддержку CSS близкую к браузерной. Но, как выяснилось, у этого выбора есть свои подводные камни — о них и поговорим.
Подключаем iText8 — первые грабли в pom.xml
Теория — это прекрасно, но давайте сразу к делу. Прежде чем писать код, разберёмся с зависимостями. Здесь нас ждёт первый сюрприз.
iText8 vs iText7 vs iText5 — почему версии это важно
В мире iText существует несколько поколений библиотек:
iText 5.x — старая версия, больше не поддерживается
iText 7.x — полностью переписанная версия с новым API
iText 8.x — актуальная версия с улучшениями производительности
Не смешивайте их! Если в проекте есть зависимости от iText5, а вы добавляете iText8 — готовьтесь к ClassNotFoundException и часам дебага.
html2pdf — отдельная библиотека
Сам iText8 не умеет конвертировать HTML в PDF. Для этого существует отдельный модуль html2pdf, который тянет за собой itext-core.
Пример минимального pom.xml:
<dependencies>
<!-- html2pdf тянет itext-core транзитивно -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>5.0.5</version>
</dependency>
<!-- Опционально: если нужна поддержка SVG -->
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>svg</artifactId>
<version>8.0.5</version>
</dependency>
</dependencies>
Важно: версии html2pdf и модулей itext-core должны быть совместимы.
⚠️ Лицензирование — читать обязательно!
Вот где большинство разработчиков получает неприятный сюрприз. iText8 распространяется под лицензией AGPL v3. Что это значит на практике?
AGPL требует:
Если вы используете iText8 в своём приложении и предоставляете его пользователям (включая SaaS!) — весь ваш код должен быть открыт под AGPL
Это касается даже сетевого взаимодействия — не только распространения бинарников
Когда нужна коммерческая лицензия:
Закрытые проекты (вы не хотите открывать исходный код)
SaaS-приложения
Продажа ПО с iText8 внутри
Альтернативы для коммерческих проектов:
Библиотека |
Лицензия |
Комментарий |
iText8 Commercial |
Коммерческая |
От ~$2000/год |
OpenPDF |
LGPL/MPL |
Можно использовать в закрытых проектах |
Flying Saucer |
LGPL |
Подходит для коммерческих проектов |
Apache PDFBox |
Apache 2.0 |
Полностью свободная, но без HTML в PDF |
Первая конвертация — «Hello, PDF!»
С зависимостями разобрались. Теперь давайте уже напишем код!
HtmlConverter — точка входа
Главный класс для конвертации — HtmlConverter. У него есть несколько перегрузок convertToPdf():
// Из строки в файл
HtmlConverter.convertToPdf(htmlString, outputStream);
// Из InputStream в OutputStream
HtmlConverter.convertToPdf(inputStream, outputStream);
// С настройками
HtmlConverter.convertToPdf(htmlString, outputStream, converterProperties);
Минимальный рабочий пример
package com.example.itext;
import com.itextpdf.html2pdf.HtmlConverter;
import java.io.FileOutputStream;
import java.io.IOException;
public class BasicHtmlToPdf {
public static void main(String[] args) {
String html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Первый PDF</title>
<style>
body { font-family: sans-serif; padding: 20px; }
h1 { color: #2c3e50; }
</style>
</head>
<body>
<h1>Привет, PDF!</h1>
<p>Это мой первый PDF-документ, созданный из HTML.</p>
</body>
</html>
""";
try (FileOutputStream outputStream = new FileOutputStream("hello.pdf")) {
HtmlConverter.convertToPdf(html, outputStream);
System.out.println("PDF успешно создан!");
} catch (IOException e) {
e.printStackTrace();
}
}
}
Запустите — и вуаля! В текущей директории появится hello.pdf. Всего 15 строк кода, и у нас есть рабочая конвертация.
Сервис для Spring Boot
В реальном приложении нам нужен сервис, который можно инжектить. Вот production-ready версия:
@Service
public class HtmlToPdfService {
public ByteArrayOutputStream convertHtmlToPdf(String html, String baseUri)
throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ConverterProperties properties = new ConverterProperties();
if (baseUri != null && !baseUri.isEmpty()) {
properties.setBaseUri(baseUri);
}
try (var inputStream = new ByteArrayInputStream(
html.getBytes(StandardCharsets.UTF_8))) {
HtmlConverter.convertToPdf(inputStream, outputStream, properties);
}
return outputStream;
}
}
Скрытый текст
ByteArrayOutputStream удобен тем, что его можно:
Вернуть через REST-контроллер как
byte[]Сохранить в файл
Отправить в S3 или MinIO
Передать дальше по цепочке обработки
Обработка исключений — чтобы не было мучительно больно
Знаете, что хуже нерабочего PDF? Непонятная ошибка NullPointerException без контекста в логах в три часа ночи. Давайте сделаем обработку ошибок человеческой:
@Service
public class SafeHtmlToPdfService {
private static final Logger log = LoggerFactory.getLogger(SafeHtmlToPdfService.class);
public ByteArrayOutputStream convertHtmlToPdfSafe(String html, String baseUri) {
log.debug("Начинаем конвертацию. Размер HTML: {} символов", html.length());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ConverterProperties properties = new ConverterProperties();
if (baseUri != null) {
properties.setBaseUri(baseUri);
}
try (var inputStream = new ByteArrayInputStream(
html.getBytes(StandardCharsets.UTF_8))) {
HtmlConverter.convertToPdf(inputStream, outputStream, properties);
log.info("PDF создан. Размер: {} байт", outputStream.size());
return outputStream;
} catch (Html2PdfException e) {
log.error("Ошибка парсинга HTML/CSS: {}", e.getMessage());
throw new PdfGenerationException("Невалидный HTML: " + e.getMessage(), e);
} catch (IOException e) {
log.error("Ошибка I/O при конвертации: {}", e.getMessage());
throw new PdfGenerationException("Ошибка генерации PDF", e);
}
}
}
Обратите внимание: мы логируем размер HTML на входе и размер PDF на выходе. Это поможет при отладке проблем с производительностью.
Thymeleaf + iText8 — динамические шаблоны
Итак, у нас есть базовая конвертация. Но статичный HTML — это мило, а в реальной жизни нам нужны динамические данные. Счёт должен содержать имя клиента, список товаров, итоговую сумму. Здесь на сцену выходит Thymeleaf.
Почему Thymeleaf?
Знакомый синтаксис (если вы работали со Spring MVC)
Шаблоны можно открыть в браузере без сервера
Мощная логика: циклы, условия, форматирование
Хорошая интеграция с Spring
Настройка TemplateEngine
Для генерации PDF нам не нужен Spring MVC. Настроим TemplateEngine программно:
public class TemplateEngineConfig {
public static TemplateEngine createTemplateEngine(String templatesPrefix) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
// Путь к шаблонам в resources
resolver.setPrefix(templatesPrefix);
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(true); // В проде — true!
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
}
Шаблоны должны лежать в src/main/resources/templates/ (или в указанном prefix).
Пример: генерация счёта
Допустим, у нас есть шаблон invoice.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ru">
<head>
<meta charset="UTF-8"/>
<title th:text="'Счёт ' + ${invoice.invoiceNumber}">Счёт</title>
<style>
body { font-family: sans-serif; }
.header { border-bottom: 2px solid #2c3e50; }
.items-table { width: 100%; border-collapse: collapse; }
.items-table th, .items-table td {
border: 1px solid #ddd;
padding: 10px;
}
</style>
</head>
<body>
<div class="header">
<h1>Счёт <span th:text="${invoice.invoiceNumber}">INV-001</span></h1>
<p th:text="${#temporals.format(invoice.invoiceDate, 'dd.MM.yyyy')}">01.01.2024</p>
</div>
<h2>Заказчик:</h2>
<p th:text="${invoice.customer.name}">Имя клиента</p>
<table class="items-table">
<tr th:each="item : ${invoice.items}">
<td th:text="${item.description}">Товар</td>
<td th:text="${item.quantity}">1</td>
<td th:text="${#numbers.formatDecimal(item.price, 1, 2)} + ' ₽'">0.00 ₽</td>
</tr>
</table>
<p><strong>Итого: </strong>
<span th:text="${#numbers.formatDecimal(invoice.totalAmount, 1, 2)} + ' ₽'">0.00 ₽</span>
</p>
</body>
</html>
И код генерации:
public class InvoiceGenerator {
private final TemplateEngine templateEngine;
public InvoiceGenerator() {
this.templateEngine = TemplateEngineConfig.createTemplateEngine("templates/");
}
public byte[] generateInvoicePdf(InvoiceData invoice) throws IOException {
// Шаг 1: Создаём контекст с данными
Context context = new Context(new Locale("ru"));
context.setVariable("invoice", invoice);
// Шаг 2: Рендерим шаблон в HTML
String htmlContent = templateEngine.process("invoice", context);
// Шаг 3: Конвертируем в PDF
ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream();
ConverterProperties properties = new ConverterProperties();
String baseUri = getClass().getClassLoader()
.getResource("templates/").toString();
properties.setBaseUri(baseUri);
HtmlConverter.convertToPdf(htmlContent, pdfOutput, properties);
return pdfOutput.toByteArray();
}
}
Процесс простой: DTO в Thymeleaf в HTML-строка в iText8 в PDF. Каждый шаг можно отладить отдельно.
Когда Thymeleaf капризничает
Thymeleaf может упасть по разным причинам: шаблон не найден, переменная отсутствует, синтаксическая ошибка. Важно обрабатывать эти ошибки:
public byte[] generatePdfSafe(String templateName, Map<String, Object> variables) {
try {
Context context = new Context(new Locale("ru"));
variables.forEach(context::setVariable);
String html = templateEngine.process(templateName, context);
return convertHtmlToPdf(html);
} catch (TemplateInputException e) {
log.error("Шаблон '{}' не найден", templateName);
return generateErrorPdf("Шаблон не найден", templateName);
} catch (TemplateEngineException e) {
log.error("Ошибка в шаблоне '{}': {}", templateName, e.getMessage());
return generateErrorPdf("Ошибка шаблона", e.getMessage());
}
}
Метод generateErrorPdf() создаёт PDF с сообщением об ошибке — пользователь получит хоть что-то, а не 500-ю ошибку.
Стили и CSS — где iText8 говорит «нет»
Шаблоны настроены, данные подставляются. А теперь — самое интересное. Вы сверстали красивый шаблон с flexbox и grid, открыли в браузере — красота! Конвертировали в PDF — и получили кашу. Знакомьтесь: ограничения CSS в iText8.
Что работает
Хорошие новости — базовый CSS работает отлично:
/* ✅ Работает */
body {
font-family: sans-serif;
font-size: 12pt;
color: #333;
}
table {
width: 100%;
border-collapse: collapse;
}
.box {
border: 1px solid #ddd;
border-radius: 8px; /* Да, border-radius работает! */
padding: 20px;
margin: 10px;
}
position: absolute; /* Работает */
position: fixed; /* Работает — фиксация относительно страницы PDF */
Что НЕ работает
А вот плохие новости:
/* ❌ НЕ работает */
display: flex;
display: grid;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
text-shadow: 1px 1px 2px #000;
/* ❌ @media queries бессмысленны */
@media print { ... } /* PDF — не экран, игнорируется */
Flexbox и Grid — это боль. Придётся использовать таблицы для раскладки, как в 2005-м. Да, я тоже вздохнул.
@page — ваш друг для PDF
Есть специальное CSS-правило, которое работает только в PDF:
@page {
size: A4; /* Размер страницы */
margin: 20mm 15mm; /* Поля */
}
@page :first {
margin-top: 30mm; /* Больше отступ на первой странице */
}
baseUri — решаем проблему путей
Если в CSS есть background-image: url('images/bg.png'), iText8 должен знать, где искать этот файл. Для этого используется baseUri:
ConverterProperties properties = new ConverterProperties();
// Для ресурсов из classpath
String baseUri = getClass().getClassLoader()
.getResource("templates/").toString();
properties.setBaseUri(baseUri);
// Или для файловой системы
properties.setBaseUri("file:///var/www/templates/");
Без правильного baseUri изображения и внешние CSS просто не загрузятся.
Работа с изображениями — когда картинка не хочет появляться
CSS разобрали, теперь изображения. Они в PDF — отдельная история. Могут не загрузиться, быть слишком большими или просто сломать рендеринг.
Типичные проблемы
Относительные пути — работают только с правильным
baseUri404 ошибки — если изображение недоступно, PDF может сломаться
Большие файлы — 10MB картинка = 10MB PDF = медленная генерация
Внешние URL — сетевые таймауты при генерации
Валидация изображений перед конвертацией
Разумная практика — проверить доступность всех изображений до конвертации:
public class ImageValidator {
public boolean isImageAccessible(String imageUrl, int timeoutMs) {
if (imageUrl.startsWith("data:")) {
return true; // Base64 всегда "доступен"
}
try {
URL url = URI.create(imageUrl).toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
connection.setConnectTimeout(timeoutMs);
connection.setReadTimeout(timeoutMs);
int responseCode = connection.getResponseCode();
return responseCode >= 200 && responseCode < 400;
} catch (IOException e) {
return false;
}
}
}
Base64-кодирование — когда это оправдано
Иногда проще встроить изображение прямо в HTML:
public String encodeToDataUri(byte[] imageBytes, String mimeType) {
String base64 = Base64.getEncoder().encodeToString(imageBytes);
return "data:" + mimeType + ";base64," + base64;
}
Скрытый текст
Используйте Base64 для:
Маленьких изображений (< 100KB)
Email-вложений (один файл = один PDF)
Ситуаций, когда внешние ресурсы недоступны
Не используйте для:
Больших изображений (раздует PDF)
Повторяющихся изображений (лучше кэшировать)
Предобработка HTML с Jsoup
Перед конвертацией полезно «почистить» HTML и заменить недоступные изображения на placeholder:
public String replaceInvalidImages(String html, String placeholder) {
Document doc = Jsoup.parse(html);
Elements images = doc.select("img[src]");
for (Element img : images) {
String src = img.attr("src");
if (!src.startsWith("data:") && !imageValidator.isImageAccessible(src)) {
log.warn("Изображение недоступно: {}", src);
img.attr("src", placeholder);
img.attr("data-original-src", src); // Сохраняем для дебага
}
}
return doc.html();
}
Размер страницы — не только A4
Изображения приручили, теперь разберёмся с размерами. По умолчанию iText8 создаёт PDF с размером A4. Но что если нужен Letter, A5, или вообще кастомный размер для «скриншота»?
PdfDocument и PageSize
Для кастомных размеров нам понадобится работать с PdfDocument напрямую:
public ByteArrayOutputStream convertWithCustomSize(
String html,
float widthPoints,
float heightPoints) throws IOException {
ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream();
// Создаём PdfWriter и PdfDocument
PdfWriter writer = new PdfWriter(pdfOutput);
PdfDocument pdfDoc = new PdfDocument(writer);
// Устанавливаем размер страницы
PageSize customSize = new PageSize(widthPoints, heightPoints);
pdfDoc.setDefaultPageSize(customSize);
// Конвертируем
ConverterProperties properties = new ConverterProperties();
try (var inputStream = new ByteArrayInputStream(
html.getBytes(StandardCharsets.UTF_8))) {
HtmlConverter.convertToPdf(inputStream, pdfDoc, properties);
}
return pdfOutput;
}
Скрытый текст
Единицы измерения
В iText размеры указываются в points (пунктах):
1 дюйм = 72 points
1 мм ≈ 2.83 points
Стандартные размеры:
A4: 595 × 842 points (210 × 297 мм)
Letter: 612 × 792 points (8.5 × 11 дюймов)
A5: 420 × 595 points (148 × 210 мм)
Кейс: генерация «скриншотов»
Для превью документов часто нужен фиксированный размер, например 760 × 370 points:
public ByteArrayOutputStream convertToScreenshot(String html) throws IOException {
return convertWithCustomSize(html, 760, 370);
}
Такие «скриншоты» отлично подходят для встраивания в другие документы или для отображения превью в интерфейсе.
Шрифты — кириллица и другие приключения
Размеры настроили, а теперь — моя любимая история. Знаете, что я увидел, когда впервые попробовал вывести русский текст? Квадратики. Много квадратиков. «□□□□□ □□□!» вместо «Привет, мир!»
Проблема в том, что стандартные PDF-шрифты (Helvetica, Times, Courier) не содержат кириллицы.
FontProvider — спасение
FontProvider управляет шрифтами в iText8. DefaultFontProvider включает базовые шрифты и (опционально) шрифты из поставки iText:
public ConverterProperties createConverterPropertiesWithFonts() {
FontProvider fontProvider = new DefaultFontProvider(
true, // Стандартные PDF-шрифты
true, // Шрифты iText (включая кириллицу!)
false // Системные шрифты (медленно)
);
ConverterProperties properties = new ConverterProperties();
properties.setFontProvider(fontProvider);
return properties;
}
Второй параметр (true) — ключевой. Он включает шрифты, поставляемые с iText, среди которых есть поддерживающие кириллицу.
Добавление кастомных шрифтов
Если нужен конкретный шрифт (корпоративный, например):
FontProvider fontProvider = new DefaultFontProvider(true, true, false);
// Добавляем шрифт из resources
String fontPath = getClass().getClassLoader()
.getResource("fonts/CustomFont.ttf").toString();
fontProvider.addFont(fontPath);
// Или целую директорию
fontProvider.addDirectory("/path/to/fonts/");
Когда шрифт не хочет загружаться
Шрифт может не загрузиться по разным причинам: файл не найден, неправильный формат, нет прав. Важно это обрабатывать:
public FontProvider createFontProviderWithFallback(String primaryFontPath) {
FontProvider fontProvider = new DefaultFontProvider(true, true, false);
try {
if (Files.exists(Path.of(primaryFontPath))) {
fontProvider.addFont(primaryFontPath);
log.info("Загружен шрифт: {}", primaryFontPath);
} else {
log.warn("Шрифт не найден: {}, используем встроенные", primaryFontPath);
}
} catch (Exception e) {
log.warn("Ошибка загрузки шрифта: {}", e.getMessage());
}
return fontProvider;
}
Production практики
Мы прошли долгий путь: от базовой конвертации до шрифтов и изображений. Теперь поговорим о том, как сделать генерацию PDF надёжной и быстрой в production.
Кэширование TemplateEngine и FontProvider
Создание TemplateEngine и FontProvider — дорогие операции. Не создавайте их на каждый запрос!
@Configuration
public class PdfGenerationConfig {
@Bean
@Lazy
public TemplateEngine pdfTemplateEngine() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("templates/pdf/");
resolver.setSuffix(".html");
resolver.setCacheable(true); // Кэшируем шаблоны!
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
@Bean
@Lazy
public FontProvider pdfFontProvider() {
// Создаём один раз — переиспользуем везде
return new DefaultFontProvider(true, true, false);
}
}
@Lazy — чтобы не тратить время на старте, если PDF не нужны сразу.
Асинхронная генерация
Для высоконагруженных систем генерация PDF должна быть асинхронной:
@Service
public class AsyncPdfService {
@Async("pdfExecutor")
public CompletableFuture<byte[]> generatePdfAsync(String html) {
try {
byte[] pdf = generatePdfSync(html);
return CompletableFuture.completedFuture(pdf);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
}
Важно: @Async("pdfExecutor") требует настроенного bean'а с именем pdfExecutor. Добавьте конфигурацию:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "pdfExecutor")
public Executor pdfExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 10, // core и max размер пула
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100), // очередь задач
new ThreadPoolExecutor.CallerRunsPolicy()
);
return executor;
}
}
Пул потоков должен быть ограничен — генерация PDF съедает память.
Retry-логика с exponential backoff
Если HTML содержит внешние ресурсы, сетевые ошибки неизбежны. Retry помогает:
public byte[] convertWithRetry(String html, int maxAttempts) {
int attempt = 0;
Exception lastException = null;
while (attempt < maxAttempts) {
attempt++;
try {
return convertPdf(html);
} catch (IOException e) {
lastException = e;
if (isRetryable(e) && attempt < maxAttempts) {
long delay = (long) (1000 * Math.pow(2, attempt - 1)); // Экспоненциальная задержка
Thread.sleep(delay);
}
}
}
throw new PdfGenerationException("Не удалось создать PDF", lastException);
}
Метрики с Micrometer
Без метрик вы не узнаете о проблемах, пока пользователи не начнут жаловаться:
@Service
public class MeteredPdfService {
private final Timer pdfGenerationTimer;
private final Counter pdfSuccessCounter;
private final Counter pdfFailureCounter;
public MeteredPdfService(MeterRegistry registry) {
this.pdfGenerationTimer = Timer.builder("pdf.generation.time")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
// ... остальные метрики
}
public byte[] generatePdf(String html) {
return pdfGenerationTimer.record(() -> {
try {
byte[] pdf = doGeneratePdf(html);
pdfSuccessCounter.increment();
return pdf;
} catch (Exception e) {
pdfFailureCounter.increment();
throw e;
}
});
}
}
Рекомендую следить за:
Время генерации (среднее, p95, p99)
Размер файлов
Частота ошибок
Количество активных генераций
Когда iText8 — не лучший выбор
iText8 — мощный инструмент, но не универсальный. Вот когда стоит посмотреть на альтернативы:
Сценарий |
Лучший выбор |
Почему |
|---|---|---|
Сложные CSS3 (flexbox, grid) |
wkhtmltopdf, Puppeteer |
Полный браузерный рендеринг |
Закрытый коммерческий проект |
Flying Saucer, OpenPDF |
LGPL лицензия |
Минимальные требования к CSS |
Flying Saucer |
Проще, легче |
PDF с JavaScript |
Headless Chrome |
JS исполняется |
Максимальный контроль над PDF |
iText8 |
Программный API |
Бонус — PDF в изображение
Мы почти закончили. Но напоследок — приятный бонус. Иногда нужно не просто PDF, а изображение: превью для галереи, миниатюра для email, «скриншот» для отчёта. Apache PDFBox поможет.
PDFBox: рендеринг страниц
public List<BufferedImage> renderPdfToImages(byte[] pdfBytes, float dpi)
throws IOException {
List<BufferedImage> images = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image = renderer.renderImageWithDPI(page, dpi, ImageType.RGB);
images.add(image);
}
}
return images;
}
Скрытый текст
DPI влияет на качество и размер:
72 DPI — для превью, быстро, маленький размер
150 DPI — хороший баланс
300 DPI — для печати, большие файлы
Полный pipeline: HTML в PDF в Image
public byte[] createPreviewImage(String html) throws IOException {
// HTML в PDF
ByteArrayOutputStream pdfOutput = new ByteArrayOutputStream();
HtmlConverter.convertToPdf(html, pdfOutput);
// PDF в Image
BufferedImage image = renderFirstPage(pdfOutput.toByteArray(), 150f);
// Image в PNG bytes
ByteArrayOutputStream imageOutput = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", imageOutput);
return imageOutput.toByteArray();
}
Сохранение в разных форматах
// PNG — без потерь, поддержка прозрачности
ImageIO.write(image, "PNG", outputStream);
// JPEG — меньше размер, без прозрачности
ImageIO.write(ensureRgb(image), "JPEG", outputStream);
Для JPEG нужно конвертировать изображение в RGB, иначе будут артефакты.
Заключение и чеклист
Мы прошли путь от «Hello, PDF» до production-ready сервиса с метриками и retry-логикой. Давайте подведём итоги.
Когда использовать iText8
✅ Используйте, если:
Нужна хорошая поддержка CSS (но не flexbox/grid)
Важна производительность
Есть бюджет на коммерческую лицензию (или проект open source)
Нужен полный программный контроль над PDF
❌ Не используйте, если:
Закрытый проект без бюджета на лицензию
Критичен идеальный CSS3-рендеринг
Нужен JavaScript в шаблонах
Чеклист для нового проекта
Перед началом:
Проверить лицензию — AGPL подходит?
Определить целевые форматы страниц
Понять, какие CSS-фичи нужны
При настройке:
Прави��ьные версии зависимостей в pom.xml
FontProvider с поддержкой кириллицы
Singleton для TemplateEngine и FontProvider
Корректный baseUri для ресурсов
В production:
Обработка всех типов исключений
Логирование с контекстом
Метрики генерации
Таймауты для внешних ресурсов
Retry-логика при сетевых ошибках
Ограничение пула потоков
Типичные грабли
Проблема |
Решение |
|---|---|
Квадратики вместо кириллицы |
|
Изображения не загружаются |
Проверить |
Flexbox не работает |
Использовать таблицы |
PDF пустой или сломанный |
Валидировать HTML через Jsoup |
Медленная генерация |
Кэшировать TemplateEngine, FontProvider |
OutOfMemoryError |
Ограничить размер HTML и изображений |
Надеюсь, эта статья сэкономит вам пару бессонных ночей. Если у вас есть свои кейсы или вопросы по iText8 — делитесь в комментариях! Особенно интересно узнать, с какими подводными камнями столкнулись вы. А может, вы нашли элегантное решение для flexbox-раскладки в PDF? Расскажите!
Комментарии (2)

nikulin_krd
12.12.2025 01:08А чем не угодил Playwright или Puppeteer развернутые где-нибудь в докере и конвертирующие HTML в PDF так как это должно отображаться в браузере? По-моему, намного меньше возни чем вот с этим
arx3889
Weasyprint не без багов (например, padding, которого не должно быть), но справляется с
display: flex;. Вообще говоря, у него поведение отличается от версии к версии - надо подбирать. И это пайтон.