Ищете способ создать насыщенный дизайном, управляемый данными, красиво оформленный PDF-отчет - на стороне сервера с инструментами, аналогичными тем, которые вы уже используете во внешнем интерфейсе? Прекратите поиск в Google. Вы пришли в нужное место. Несколько месяцев назад я был в той же лодке, что и вы, когда помогал клиенту решить именно эту проблему. Чтобы добиться этого, я разработал четырехэтапное решение с использованием Puppeteer, D3 и руля. В этом посте я дам вам пошаговые инструкции по созданию серверных отчетов в формате pdf. Давайте погрузимся.

В этом посте мы рассмотрим

  • Настройка Puppeteer & Handlebars
  • Создание генератора для создания нашего PDF-файла
  • Создание шаблона руля
  • Добавляем последние штрихи

Проблемы создания этих отчетов в формате PDF:

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

  • Страницы нужно будет ограничить вручную.
  • У нас не будет доступа к css media props, кроме screen. (без «разрыва страницы» или типа носителя для печати)
  • После компиляции и рендеринга PDF-файла мы не сможем использовать инструменты разработчика для устранения неполадок.
  • Сам Puppeteer увеличивает время сборки и размер ваших развертываний.
  • Создание отчета может занять некоторое время в зависимости от размера файла.

В этом примере предположим…

У нас уже есть база для нашего проекта, работающая на Node / Express, а также некоторые типы решений ORM и DB. Мы готовы внести наши приятные и приятные данные в отчет.

Инструменты, необходимые для этого:

1. Руль

Фреймворк для создания шаблонов HTML из семейства Moustache. Это позволяет использовать частичное создание шаблонов (модные разговоры о компонентах), а также настраиваемые и встроенные вспомогательные функции для расширения нашей логики.

npm install handlebars

Пример использования частичных и встроенных блоков

{{#each poleComparison as |page|}}
<div class="page">
  {{#each page.pairs as |polePair|}}
	{{> comparison-header polePair=polePair }}
	    <div class="comparison-tables">
		    {{> comparison-body polePair=polePair }}
	    </div>
  {{/each}}
  {{> footer @root }}
</div>
{{/each}}

Кукловод

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

npm install puppeteer

Список вариантов использования:

  • Создавайте скриншоты и PDF-файлы страниц.
  • Сканируйте SPA (одностраничное приложение) и генерируйте предварительно отрисованный контент (т. Е. «SSR» (рендеринг на стороне сервера)).
  • Создайте современную автоматизированную среду тестирования.
  • Протестируйте расширения Chrome.

3. D3 (документы на основе данных)

D3.js - это библиотека JavaScript для управления документами на основе данных. D3 помогает оживить данные с помощью HTML, SVG и CSS. Акцент D3 на веб-стандартах дает вам все возможности современных браузеров, не привязывая себя к проприетарной платформе, сочетая мощные компоненты визуализации и управляемый данными подход к манипуляциям с DOM.

<script src="https://d3js.org/d3.v5.min.js"></script>

Шаг первый: настройка Puppeteer & Handlebars

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

const puppeteer = require("puppeteer"); 
const hbs = require("handlebars");

Затем нам нужно позволить Handlebars скомпилировать наш шаблон. Мы создадим функцию компиляции, которая найдет файл .hbs и будет использовать для этого встроенные в метод компиляции ручки.

const puppeteer = require("puppeteer");
const hbs = require("handlebars");
 
const compile = async (templateName, data) => {
	const filePath = path.join(__dirname, "templates", `${templateName}.hbs`);
	if (!filePath) {
		throw new Error(`Could not find ${templateName}.hbs in generatePDF`);
	}
	const html = await fs.readFile(filePath, "utf-8");
	return hbs.compile(html)(data);
};

Этот метод позволяет нам также вводить данные, которые мы будем использовать, в наш шаблон.

Наконец, нам нужно настроить нашу функцию generatePDF. Его работа будет заключаться в том, чтобы открыть экземпляр Chromium без головы кукольника, чтобы преобразовать наш шаблон в формат PDF.

let browser; 
const generatePDF = async (fileName, data) => {
	try {
		if (!browser) {
			browser = await puppeteer.launch({
				args: [
				"--no-sandbox",
				"--disable-setuid-sandbox",
				"--disable-dev-shm-usage"
				],
				headless: true,
			})
		}
	} catch (err) {
		...

Мы передали нашему браузеру кукловодов некоторые параметры конфигурации, которые сделают его безголовым и легким. Мы также не хотим, чтобы несколько браузеров были открыты одновременно, это может вызвать проблемы с производительностью при создании нескольких отчетов.

Затем мы создадим новый контекст браузера в режиме инкогнито. Мы будем использовать этот метод вместо обычного контекстного метода, потому что он не будет передавать файлы cookie / кеш-файлы другим контекстам браузера. Это полезно для других функций кукловода, но в этом процессе не потребуется.

],
				headless: true,
			})
		}
		const context = await browser.createIncognitoBrowserContext();
		const page = await context.newPage();
		const content = await compile(fileName, data);
 
	} catch (err) {
		...
	}
}

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

const content = await compile(fileName, data);
 
		await page.goto(`data: text/html, ${content}`, { 
			waitUntil: "networkidle0" 
		});
		await page.setContent(content);
		await page.emulateMedia("screen");
 
	} catch (err) {
		...
	}
}

* page.goto принимает строку URL и параметры конфигурации. Мы не будем переходить по URL, вместо этого мы будем использовать наш скомпилированный HTML

* emulateMedia изменяет тип мультимедиа CSS, используемый на странице. Нам нужно, чтобы наш тип мультимедиа отражал CSS, используемый для экранов.

Нам нужно настроить формат нашей страницы, чтобы кукольник знал, как ее отображать. Имейте в виду, что кукловод не имеет представления о том, где мы хотим разделить наш фактический контент (это будет обработано позже с помощью наших шаблонов css).

await page.emulateMedia("screen");
 
		const pdf = await page.pdf({
			format: "A4",
			printBackground: true,
		});
 
		await context.close();
		return pdf;
 
	} catch (err) {
		...
	}
}

Шаг второй: настройте наши шаблоны руля

Мы начнем с создания нашего первого файла шаблона Handlebars для нашего отчета. Обратите внимание, что синтаксис выглядит и действует как обычный HTML.

our_report.hbs
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Our Cool PDF Report</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
  <script src="https://d3js.org/d3.v5.min.js"></script>
</head>
  <body>
	  <div>
	    <p>Hello World<p>
	  </div>
  </body>

Давайте перенесем наши данные в наш шаблон

Мы можем использовать некоторые встроенные блоки ручек, чтобы помочь нам взаимодействовать с данными, которые мы внедрили ранее в нашу функцию компиляции. Мы можем использовать блок «with» для получения контекста для данных, которые нам нужны, а затем блок «each» для итерации по ним.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Our Cool PDF Report</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
  <script src="https://d3js.org/d3.v5.min.js"></script>
</head>
  <body>
	  <div>
		{{#with Data as |myData| }}
		 {{#each myData.text as |text| }}
		 <p>{{text}}<p>
		 {{/each}}
		{{/with }}
	  </div>
  </body>

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

Теперь в нашем приложении узла мы можем использовать нашу функцию generatePDF для создания нашего PDF-файла. Пришло время решить, что вы в конечном итоге хотите делать с отчетом. Вы можете сохранить его в своей базе данных, передать на стороне клиента или спрятать в корзину S3. Здесь есть большая свобода в зависимости от того, что нужно вашему приложению.

const generatePDF = require('./generatePDF.js');
 
const generateReportWithData = reportData => {
 return generatePDF("our_report", reportData);
  }

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

Шаг третий: создайте шаблон руля

Теперь мы можем настроить стиль нашего шаблона. Мы создадим файл с именем style.hbs. Мне нравится настраивать глобальные переменные css-переменные, чтобы я был честен с большинством моих стилей. pt - рекомендуемая единица измерения для документов, предназначенных для печати, и я обнаружил, что пиксель не всегда так хорошо подходит для текста. Я также обнаружил, что единицы em лучше переводят расстояние между буквами, чем пиксели. Это упростило согласование дизайна кернинга / межбуквенного интервала при преобразовании значений.

Создание ограничений страницы

style.hbs
:root {
	--font-s-small: 8pt;
	--font-s-normal: 10pt;
	--font-s-mid: 12pt;
	--font-s-large: 14pt;
	/* Kerning */
	--ltr-spc-200: 0.2em;
	--ltr-spc-100: 0.1em;
	--ltr-spc-020: 0.02em;
	--ltr-spc-025: 0.025em;
}

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

<style>
html, body {
	height: 100%;
	margin: 0;
	padding: 0;
	}
.page {
	background: white;
	display: block;
	margin: 0 auto;
	margin-bottom: 8.5em;
	/* Size = A4 */
	width: 21cm;
	height: 29.7cm;
	padding: 5em 30px 0 30px;
	position: relative;

В файле, где вы устанавливаете кукловод и руль

Поскольку мы создали файл style.hbs, нам нужно зарегистрировать его как партиал, чтобы мы могли просто вставить его в наш шаблон. Таким образом, нам не придется вставлять все стили в основной файл шаблона, и мы сможем повторно использовать код, если потребуется.

hbs.registerPartial("style", fs.readFileSync(
    path.join(__dirname, "/path/to/style.hbs"), "utf-8"),
);

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

{{> styles }}
</head>
  <body>
	  <div class="page">
		{{#with Data as |myData| }}
		 {{#each myData.text as |text| }}
		 <p>{{text}}<p>
		 {{/each}}
		{{/with }}
	  </div>
  </body>

Шаг четвертый: добавляем немного d3

Мы уже добавили ссылку на CDN d3 в заголовок нашего шаблона.

<script src="https://d3js.org/d3.v5.min.js"></script>

Пришло время создать партиал для нашего сценария d3. Мы сделаем это, используя тот же метод, который мы использовали для создания партиала style.hbs. Зарегистрируйте файл d3_script.hbs в качестве помощника по рулю.

hbs.registerPartial("d3_script", fs.readFileSync(
	path.join(__dirname, "/path/to/d3_script.hbs"), "utf-8"),
);

Затем мы можем добавить его в наш основной шаблон по мере необходимости. Также обратите внимание на div привязки холста, используемый в шаблоне, чтобы дать нашему D3 основу для начала.

my_template.hbs
{{/each}}
	{{/with }}
  </div>
  <div class="canvas"></div>
</body>
 
<script type="text/javascript">
const svg = d3
	.select('#canvas')
	.append('svg')
	.attr('viewBox', [-width / 2, -height / 2, width, height]);
</script>