React PDF: генерация PDF в React — 5 примеров
Практическое руководство по @react-pdf/renderer: пять примеров PDF-документов с кириллицей, таблицами, логотипом, фотографиями и нумерацией страниц. Попробуйте бесплатную демонстрацию и скачайте результат.
PDF нужен там, где результат должен оставаться одинаковым у автора и получателя: расчет стоимости, отчет, памятка, подборка фотографий. В React-приложении такой документ можно описать компонентами и сформировать из данных. В этой статье разберем @react-pdf/renderer: от короткого текста на русском до таблицы, фирменной шапки и отчета на нескольких страницах.
Ниже — бесплатные интерактивные примеры: письмо с кириллицей, расчет стоимости, оформленное предложение, многостраничный отчет и изображения с подписями. Выберите документ, измените данные и скачайте PDF. Для использования примеров регистрация не нужна.
Пять документов. Один подход.
Выберите пример, измените содержание и получите настоящий PDF.
2. Посмотрите результат
PDF ещё не созданНажмите «Сформировать PDF».
Все страницы можно посмотреть до скачивания.
Предпросмотр и скачивание используют один и тот же PDF. После изменения данных создайте его заново.
Примеры создаются локально в вашем браузере. Поля и выбранные изображения эта демонстрация на сервер не отправляет.
Короткие JSX-фрагменты ниже показывают принцип сборки документов, а полные расширенные шаблоны демонстрации на React.createElement доступны в архиве исходников под рабочей областью.
Что делает React PDF
Проект diegomura/react-pdf создает PDF в браузере и Node.js. Его пакет называется @react-pdf/renderer. Это полезно различать: пакет react-pdf другого автора предназначен для отображения уже существующих PDF. Сходство названий часто приводит к установке не той библиотеки. У генератора лицензия MIT; при распространении библиотеки нужно сохранять ее уведомление о лицензии.
Документ строится из Document, Page, View, Text и Image. Здесь нет браузерных div, table и img. Внешний вид задается объектами стилей, а данные передаются обычными свойствами компонентов. Например, один компонент расчета можно использовать для разных заказчиков, меняя только массив позиций.
Такой подход подходит для документов с предсказуемой структурой. Если нужно сохранить существующую HTML-страницу со всеми ее стилями, сначала стоит оценить другой способ экспорта. React PDF потребует отдельного шаблона документа. Основы описаны в официальном руководстве.
Подготовка: установка, стили и кириллица
Примеры рассчитаны на React-проект со сборкой JSX и современным автоматическим JSX runtime; при старой настройке сборщика добавьте import React from 'react'. Установите библиотеку и зафиксируйте полученную версию в lock-файле проекта. Перед обновлением зависимости проверяйте свои документы заново: переносы текста и разбиение страниц важнее того, успешно ли собрался JavaScript.
npm install @react-pdf/renderer
Для русского текста подключим шрифт с кириллицей. Поместите статические файлы NotoSans-Regular.ttf и NotoSans-Bold.ttf в каталог public/fonts вашего проекта. Это именно файлы шрифтов, а не CSS-ссылка Google Fonts. Проверьте их лицензию и возможность встраивания в PDF. В примерах используются обычное и жирное начертания, поэтому зарегистрируем оба.
import {
Document, Page, Text, View, Image,
StyleSheet, Font, BlobProvider
} from '@react-pdf/renderer';
Font.register({
family: 'NotoSans',
fonts: [
{ src: '/fonts/NotoSans-Regular.ttf', fontWeight: 400 },
{ src: '/fonts/NotoSans-Bold.ttf', fontWeight: 700 }
]
});
const base = StyleSheet.create({
page: {
padding: 36, fontFamily: 'NotoSans',
fontSize: 10, lineHeight: 1.45, color: '#203238'
},
title: { fontSize: 22, fontWeight: 700, marginBottom: 16 },
muted: { color: '#66767b', fontSize: 9 }
});
Этот блок — общий для пяти примеров ниже. StyleSheet.create не переносит в PDF весь браузерный CSS: ориентируйтесь на поддерживаемые свойства. Числовые размеры по умолчанию задаются в пунктах; для печати удобно также использовать mm. Поддерживаемые форматы и регистрацию начертаний описывает раздел о шрифтах. Для начала выбирайте статические TTF и проверяйте длинные русские слова на реальном документе.
Пример 1. Письмо или короткая памятка на русском
Начнем с памятки: заголовок, имя получателя и текст. Это удобная основа для инструкции, подтверждения заявки или сопроводительного письма. Данные отделены от оформления, поэтому текст можно получать из формы, а стиль оставить неизменным.
function SimpleDocument({ name, message }) {
return (
<Document title="Памятка" author="Моя компания">
<Page size="A4" style={base.page}>
<Text style={base.title}>Памятка для {name}</Text>
<Text>{message}</Text>
<Text style={[base.muted, { marginTop: 20 }]}>
Сохраните документ, чтобы вернуться к нему позже.
</Text>
</Page>
</Document>
);
}
title и author у Document записывают метаданные файла. Видимый заголовок мы выводим отдельно через Text. Поэтому изменение имени файла или метаданных само по себе не меняет надпись на странице. Доступные свойства перечислены в документации Document.
Пример 2. Расчет стоимости с таблицей
Таблицу соберем из строк View с одинаковыми ширинами колонок. Числа выравниваем справа, описание — слева. Для короткой ведомости достаточно трех колонок: наименование, количество и сумма. При добавлении цены за единицу уменьшите ширину описания и задайте одинаковую схему колонок шапке и всем строкам.
const table = StyleSheet.create({
row: { flexDirection: 'row', borderBottomWidth: 1,
borderBottomColor: '#d3dedb', paddingVertical: 8 },
head: { backgroundColor: '#eaf3f0', fontWeight: 700 },
name: { width: '60%', paddingHorizontal: 6 },
qty: { width: '15%', textAlign: 'right', paddingHorizontal: 6 },
amount: { width: '25%', textAlign: 'right', paddingHorizontal: 6 }
});
const rubles = value => value.toFixed(2).replace('.', ',') + ' руб.';
function CostDocument({ rows }) {
const totalKopecks = rows.reduce(
(sum, row) => sum + Math.round(row.qty * row.priceKopecks), 0
);
return (
<Document title="Расчет стоимости">
<Page size="A4" style={base.page}>
<Text style={base.title}>Расчет стоимости</Text>
<View style={[table.row, table.head]} wrap={false}>
<Text style={table.name}>Наименование</Text>
<Text style={table.qty}>Кол-во</Text>
<Text style={table.amount}>Сумма</Text>
</View>
{rows.map(row => (
<View key={row.id} style={table.row} wrap={false}>
<Text style={table.name}>{row.name}</Text>
<Text style={table.qty}>{row.qty}</Text>
<Text style={table.amount}>
{rubles(Math.round(row.qty * row.priceKopecks) / 100)}
</Text>
</View>
))}
<Text style={{ marginTop: 14, textAlign: 'right', fontWeight: 700 }}>
Итого: {rubles(totalKopecks / 100)}
</Text>
</Page>
</Document>
);
}
Например, позиция { id: 'cable', name: 'Прокладка кабеля', qty: 30, priceKopecks: 12000 } даст 3 600 рублей. Цена хранится в копейках, а сумма каждой строки округляется перед итогом. Для сложных расчетов отдельно определите правила округления, скидок и дробных количеств.
wrap={false} сохраняет короткую строку целиком. Этот прием не подходит для строки высотой больше страницы. В длинных таблицах нужно также продумать повторение шапки — приведенный фрагмент показывает ее только один раз. Сам компонент View является контейнером; структуру таблицы задаем мы.
Пример 3. Фирменная шапка и логотип
Логотип, контакты и один акцентный цвет делают разные документы узнаваемыми. Шапку лучше выделить в компонент: тогда изменение телефона или оформления не придется повторять в каждом шаблоне. Ниже — простой вариант с логотипом PNG и двумя строками текста.
function BrandHeader({ logo, company, contacts }) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center',
borderBottomWidth: 2, borderBottomColor: '#17676b',
paddingBottom: 14, marginBottom: 22 }} wrap={false}>
<Image src={logo} style={{ width: 54, height: 54,
objectFit: 'contain', marginRight: 14 }} />
<View style={{ flexGrow: 1, flexShrink: 1 }}>
<Text style={{ fontSize: 16, fontWeight: 700,
marginBottom: 8, lineHeight: 1.35 }}>
{company}
</Text>
<Text style={base.muted}>{contacts}</Text>
</View>
</View>
);
}
function BrandedDocument() {
return (
<Document title="Предложение">
<Page size="A4" style={base.page}>
<BrandHeader logo="/images/logo.png"
company="Мастерская проектов" contacts="hello@example.com" />
<Text style={base.title}>Предложение по проекту</Text>
<Text>Здесь размещаются задачи, сроки и состав результата.</Text>
</Page>
</Document>
);
}
Размер рамки логотипа задан явно, а objectFit: 'contain' сохраняет пропорции. Изображение должно быть доступно при генерации: для своего сайта проще хранить его рядом с приложением. В этом примере используются PNG и JPEG — форматы, описанные для Image. Если исходный логотип другого формата, подготовьте совместимый вариант заранее.
Пример 4. Отчет на нескольких страницах
У длинного отчета две отдельные задачи: переносить основной текст и повторять служебные элементы. Оставим Page с включенным переносом, а колонтитул сделаем фиксированным. Нижний отступ страницы резервирует место, чтобы текст не заходил на номер страницы.
function ReportDocument({ sections }) {
return (
<Document title="Отчет по проекту">
<Page size="A4" wrap
style={[base.page, { paddingBottom: 58 }]}>
<Text style={base.title}>Отчет по проекту</Text>
{sections.flatMap(section => [
<Text key={`${section.id}-title`} minPresenceAhead={64}
style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>
{section.title}
</Text>,
<Text key={`${section.id}-body`}
style={{ marginBottom: 18 }}>{section.text}</Text>
])}
<Text fixed style={{ position: 'absolute', top: 801,
left: 36, right: 36, textAlign: 'right',
fontSize: 9, lineHeight: 1.2 }}
render={({ pageNumber, totalPages }) =>
`Страница ${pageNumber} из ${totalPages}`} />
</Page>
</Document>
);
}
minPresenceAhead у заголовка резервирует место для начала следующего текста. Заголовок и основной текст здесь являются соседними элементами страницы: flatMap возвращает их одним массивом без внешнего контейнера для каждой секции. Большую секцию не оборачиваем целиком в wrap={false}, иначе она не сможет нормально продолжиться на другой странице. Переносы, break и fixed описаны в документации разбиения страниц.
Здесь положение колонтитула top: 801 задано для портретной страницы A4 высотой около 842 пунктов, а paddingBottom: 58 оставляет свободное место снизу. При другом размере или ориентации страницы пересчитайте координату. Явная верхняя координата также устраняет обнаруженное при проверке версии 4.9.0 смещение динамического текста с bottom.
Нумерацию считаем через render у Text. Эта функция вызывается при расчете раскладки и после определения общего числа страниц, поэтому внутри нее не следует менять состояние приложения или отправлять запросы. Схема аргументов приведена в разделе динамического содержимого.
Пример 5. Фотоотчет с подписями
В демонстрации выше по умолчанию размещена собственная геометрическая иллюстрация, а не фотография. Загрузите свой PNG или JPEG и измените подпись, чтобы увидеть этот же подход на реальном снимке.
Фотоотчет удобно собирать из карточек «изображение + описание». Явная высота снимка помогает заранее оценить, сколько карточек поместится на A4. Для технических фотографий выбираем contain, чтобы не обрезать детали. cover заполнит рамку полностью, но часть кадра может остаться за ее пределами.
function PhotoDocument({ photos }) {
return (
<Document title="Фотоотчет">
<Page size="A4" style={base.page}>
<Text style={base.title}>Фотоотчет</Text>
{photos.map((photo, index) => (
<View key={photo.id} wrap={false}
style={{ marginBottom: 18 }}>
<Image src={photo.src} style={{ width: '100%',
height: 240, objectFit: 'contain',
backgroundColor: '#edf2f1' }} />
<Text style={{ marginTop: 7 }}>
{index + 1}. {photo.caption}
</Text>
</View>
))}
</Page>
</Document>
);
}
Карточка должна помещаться на одной странице вместе с подписью. Для длинного пояснения сделайте отдельный текстовый блок с переносом. Перед экспортом уменьшайте чрезмерно большие снимки: несколько фотографий с телефона способны заметно увеличить объем файла и время сборки. Для подготовки изображений можно использовать наш бесплатный редактор изображений.
Как скачать PDF из React-приложения
Шаблон документа еще нужно связать с интерфейсом. BlobProvider предоставляет состояние генерации и адрес готового файла. Пока файл собирается, покажем понятное сообщение; при ошибке — текст ошибки; ссылку создадим только после успешной подготовки.
function DownloadExample() {
const document = (
<SimpleDocument name="Александр"
message="Проверьте состав работ перед началом проекта." />
);
return (
<BlobProvider document={document}>
{({ url, loading, error }) => {
if (loading) return <p role="status">Подготавливаем PDF…</p>;
if (error) return <p role="alert">Не удалось создать PDF.</p>;
if (!url) return null;
return <a href={url} download="pamyatka.pdf">Скачать PDF</a>;
}}
</BlobProvider>
);
}
У библиотеки есть и PDFDownloadLink, и usePDF для управления пересборкой, и вызов pdf(document).toBlob() без отдельного React-интерфейса. Выбор зависит от того, нужен ли немедленный результат или создание только по кнопке. Варианты собраны в официальном руководстве генерации на лету.
Для встроенного просмотра предусмотрен PDFViewer — iframe с PDF. Его отображение зависит от просмотрщика браузера; на телефоне обязательно оставьте доступное скачивание или открытие файла отдельно. Настройки компонента перечислены в документации PDFViewer. Если документ нужен на сервере, Node API предоставляет renderToFile, renderToBuffer и renderToStream; это отдельный путь, описанный в документации Node API.
Что проверить перед использованием в своем проекте
Начните с данных, на которых верстка обычно ломается: длинное название организации, пустой логотип, описание на несколько абзацев, дробное количество и отчет на десятки страниц. Проверьте первую, промежуточную и последнюю страницы в масштабе A4. Следите за обрезанными цифрами, заголовками внизу страницы и колонтитулами поверх текста.
- Шрифты. Все используемые начертания доступны, русский текст и денежные обозначения читаются.
- Расчет. Итог совпадает с исходными данными, округление определено явно, пустые значения не превращаются в
NaN. - Пагинация. Короткие строки не разрываются, высокие блоки могут переноситься, на колонтитулы оставлено место.
- Интерфейс. Есть состояние подготовки, сообщение об ошибке и понятное имя скачиваемого файла.
- Большие документы. Измерены время генерации и расход памяти на обычном телефоне и компьютере.
Для крупных отчетов я бы запускал генерацию по кнопке и при необходимости переносил вычисления в Web Worker либо в Node.js. Это инженерное решение по результатам измерений, а не обещание одинаковой скорости для любого документа. JSX-компонент в worker не передают как обычные данные: передают объект с содержимым, а шаблон создают внутри worker.
Как использовать подход на WordPress
React PDF не требует переводить весь WordPress-сайт на React. Для одной полезной страницы можно собрать отдельный JavaScript-модуль с шаблонами документов, подключить его только там и оставить навигацию, оформление страницы и серверную часть на PHP. Bootstrap 5 оформляет поля и кнопки веб-интерфейса; стили самого PDF задаются отдельно через StyleSheet.
На этой странице демонстрация отделена от текста статьи: можно сначала посмотреть результат, а затем разобраться, как он устроен. Для полноценного проекта добавляйте проверку входных данных и правила хранения. Генерация в браузере сама по себе не означает, что любой сайт никогда не отправляет данные на сервер: это определяется реализацией формы, аналитики и сетевых запросов.
Попробуйте заменить данные в одном примере своим небольшим документом. Затем добавьте шапку, таблицу и колонтитул по отдельности — так проще увидеть, какой блок изменил переносы и где нужно поправить размеры.

