Создание PDF-файлов всегда было проблемой на протяжении всей моей карьеры разработчика программного обеспечения. Большинство библиотек и реализаций PDF по-прежнему кажутся очень низкоуровневыми, и вы в основном всегда в конечном итоге создаете свой собственный механизм рендеринга.

Создать собственный движок рендеринга?

В качестве примера рассмотрим фрагмент кода, взятый из документации PDFKit:

doc.fillColor('green')
    .text(lorem.slice(0, 500), {
      width: 465,
      continued: true
    })
    .fillColor('red')
    .text(lorem.slice(500))

Приведенный выше код позволяет вам взять текст, хранящийся в переменной с именем lorem, и отобразить первые 500 символов зеленым цветом, а все последующие - красным. Но не выглядит ли это слишком сложным? Нет, это не так. Хотя в реальных условиях вы, вероятно, предпочтете выделить несколько конкретных слов в блоке текста. Для этого вам нужно будет определить соответствующие диапазоны затронутых символов в этом тексте и динамически построить что-то вроде приведенного выше кода. Теперь представьте, что вы также хотите создать макет столбцов или, возможно, создать таблицу и вставить несколько изображений. Все усложняется довольно быстро, и вы действительно можете создать механизм рендеринга PDF.

Использовать существующий движок рендеринга

Так получилось, что у нас есть очень сложные механизмы рендеринга для сложных макетов, которые называются браузерами. Однако запуск веб-браузера для рендеринга PDF-файла может вызвать много накладных расходов, и это правда. Если ваша цель - создавать PDF-файлы с максимальной эффективностью, не рекомендуется делать это с помощью браузера. Однако в любом другом случае преимущества могут намного перевесить проблемы с производительностью.

Входит кукловод.

Puppeteer - это, по сути, автоматизированный экземпляр Chromium для Node.js. Его можно использовать для многих вещей, таких как автоматическое тестирование пользовательского интерфейса, автоматическая отправка форм и просмотр веб-страниц, а также автоматическое создание снимков экрана и создание PDF-файлов.

Создать PDF-файл с помощью Puppeteer довольно просто:

const puppeteer = require('puppeteer');
(async () => {
    const browser = await puppeteer.launch()
    const page = await browser.newPage()
    await page.goto('https://www.medium.com')
    await page.pdf({path: 'medium.pdf', format: 'A4'})
    await browser.close()
})()

Итак, вы в основном запускаете браузер, открываете страницу, распечатываете страницу в файл PDF и закрываете браузер.

Вместо файла вы также можете распечатать свой PDF-файл в буфер, опуская параметр пути:

const buffer = await page.pdf({format: 'A4'})

Создание макета

Теперь все, что осталось сделать, это создать макет с помощью HTML и CSS. Фактически, вы можете использовать любую понравившуюся веб-технологию - даже JavaScript, SVG или Canvas.

Мне нравится максимально контролировать макет отдельных страниц, поэтому я счел очень полезным создать контейнер страницы:

.page {
    position: relative;
    overflow: hidden;
    padding: 0.8in;
    page-break-after: always;
}
.page.landscape {
    width: 11.7in;
    height: 8.2in;
}
.page.portrait {
    width: 8.3in;
    height: 11.6in;
}

Вы можете использовать position: relative, чтобы иметь возможность абсолютно позиционировать некоторые из ваших элементов на странице.

Вы также можете использовать page-break-after: always, чтобы принудительно разрывать страницы после каждой страницы.

Затем вы можете просто создать свои PDF-страницы в HTML:

<body>
    <div id="page1" class="page landscape">
        …
    </div>
    <div id="page2" class="page landscape">
        …
    </div>
</body>

Если вы хотите использовать альбомную ориентацию, как в приведенном выше примере, вам также нужно указать Puppeteer для печати в альбомной ориентации:

page.pdf({format: 'A4', landscape: true})

Вы также можете включить печать фона, если вам нужно использовать цвета фона или изображения:

page.pdf({format: 'A4', landscape: true, printBackground: true})

Фактически, использование фоновых изображений вместо тегов img может дать вам больше контроля при размещении изображений внутри макета:

.page .title-image {
    width: 100%;
    height: 4in;
    background: url("…") no-repeat center center;
    background-size: contain;
}
…
<div class="page landscape">
    <div class="title-image"></div>
    …
</div>

Таким образом, вы можете использовать свойства CSS, такие как background-position и background-size, для оптимального размещения ваших изображений. Это может быть особенно полезно при работе с динамическим контентом, размеры изображения которого могут отличаться.

Благодаря абсолютному позиционированию размещение верхних и нижних колонтитулов также не составляет проблем:

.page .footer {
    position: absolute;
    left: 0.8in;
    right: 0.8in;
    bottom: 0.2in;
    border-top: 1px solid #000;
    padding: 0.1in 0 0;
}
…
<div class="page landscape">
    …
    <div class="footer">…</div>
</div>

Вы получаете таблицы, границы, поля, отступы, позиции и цвета бесплатно. Разумеется, форматированный текст также поставляется с использованием CSS для изменения размеров и стилей шрифтов или с использованием таких элементов HTML, как strong или em.

Вы даже можете добавить собственное начертание шрифта, используя веб-шрифты. С помощью таких инструментов, как Transfonter, очень просто создать начертание шрифта.

Создание динамического контента

Итак, у вас есть рендерер и макет. Теперь вы, вероятно, захотите наполнить последнее динамическим контентом. В противном случае создание PDF в большинстве случаев не имело бы особого смысла.

Скорее всего, вы уже находитесь в среде веб-приложения, когда разрабатываете свой PDF-файл. В этом случае у вас, скорее всего, уже есть средства для создания и обслуживания динамических HTML-шаблонов.

Если вы этого не сделаете, вы можете использовать комбинацию Express и шаблонизатора, такого как Pug или Mustache.

Ваш код Node.js, вероятно, будет выглядеть примерно так:

const express = require('express')
const mustacheExpress = require('mustache-express')
const puppeteer = require('puppeteer')
const app = express()
app.engine('html', mustacheExpress())
app.set('view engine', 'html')
app.get('/export/html', (req, res) => {
    const templateData = { … }
    res.render('template.html', templateData)
})
app.get('/export/pdf', (req, res) => {
    (async () => {
        const browser = await puppeteer.launch()
        const page = await browser.newPage()
        await page.goto('http://localhost:3000/export/html')
        const buffer = await page.pdf({format: 'A4', …})
        res.type('application/pdf')
        res.send(buffer)
        browser.close()
    })()
})
app.listen(3000)

Вот и все. Все готово. Вызов localhost: 3000 / export / pdf запустит браузер Chromium без заголовка, вызовет l ocalhost: 3000 / export / html и отобразит его содержимое в PDF, а затем отправьте его обратно в браузер пользователя.

Конечно, вы можете захотеть сделать другие вещи с вашим PDF-файлом, например, сохранить его на диск или отправить по электронной почте. В этом случае вам может не понадобиться маршрут для / export / pdf, но базовая механика останется прежней.

И последнее: я счел целесообразным добавить еще одну опцию в метод goto Puppeteer:

page.goto('…', {waitUntil: 'networkidle0'})

Параметр waitUntil networkidle0 указывает Puppeteer считать, что страница полностью загружена, только если не было открытого сетевого подключения в течение как минимум 500 мс. Доступны и другие опции, которые могут быть более полезными в разных случаях.

Заключение

Используя, вероятно, наиболее распространенный механизм рендеринга для всех видов макетов, вы можете избавить себя от многих проблем при создании PDF-файлов. Дело не в том, что макеты на основе CSS всегда безболезненны и понятны, но у вас, вероятно, уже есть некоторый опыт работы с ними. Кроме того, вам не нужно беспокоиться о кросс-браузерной совместимости и тому подобном при создании макетов для одной единственной версии браузера - ваш макет должен работать только в одном конкретном месте. Это дает вам свободу легко создавать сложные и профессиональные макеты PDF для ваших приложений.

Спасибо за прочтение, надеюсь, вам понравилось. Если у вас есть какие-либо вопросы или вы хотите узнать больше о любой из обсуждаемых технологий или методов, не стесняйтесь оставлять комментарии!

Вы также можете связаться со мной на веб-сайте моей компании или в Твиттере.