
Введение
По работе я довольно часто занимаюсь созданием генераторов печати на HTML для воссоздания и замены форм, которые компания традиционно заполняла от руки на бумаге или в Excel. Это позволяет компании переходить на новые веб-инструменты, в которых форма автоматически заполняется по параметрам URL из нашей базы данных, создавая при этом тот же результат на бумаге, к которому все привыкли.
В этой статье я объясню основы CSS, управляющие внешним видом веб-страниц при печати, и дам пару советов, которые могут вам помочь в этом.
Примеры файлов
Вот несколько примеров генераторов страниц, чтобы вы поняли контекст.
Для начала я признаю, что эти страницы немного уродливые и их можно улучшить. Но они справляются с задачей и меня до сих пор не уволили.
Сопроводительная записка с вводом в боковую колонку
Сопроводительная записка с contenteditable
@page
В CSS есть правило @page, указывающее браузеру настройки печати веб-сайта. Обычно я использую
@page { size: Letter portrait; margin: 0; }
Почему я выбрал margin: 0, будет объяснено ниже. Следует использовать Letter или A4, в зависимости от ваших взаимоотношений с метрической системой.
Установка размера и полей (margin) @page — это не то же самое, что установка ширины, высоты и полей элемента <html> или <body>. @page находится за пределами DOM — она содержит DOM. В вебе элемент <html> ограничен краями экрана, а при печати он ограничен @page.
Контролируемые @page параметры более-менее соответствуют параметрам в диалоговом окне печати браузера, открывающемся при нажатии Ctrl+P.
Вот пример файла, который я использовал для экспериментов:
<!DOCTYPE html> <html> <style> @page { /* см. ниже информацию по каждому из экспериментов */ } html { width: 100%; height: 100%; background-color: lightblue; /* сетка создана shunryu111 https://stackoverflow.com/a/32861765/5430534 */ background-size: 0.25in 0.25in; background-image: linear-gradient(to right, gray 1px, transparent 1px), linear-gradient(to bottom, gray 1px, transparent 1px); } </style> <body> <h1>Sample text</h1> <p>sample text</p> </body> </html>
Вот как это выгля��ит в браузере:

А вот результаты для разных значений @page:
@page { size: Letter portrait; margin: 1in; }:

@page { size: Letter landscape; margin: 1in; }:

@page { size: Letter landscape; margin: 0; }:

Установка размера @page на самом деле не установит этот размер в лотке принтера. Вам придётся менять его самостоятельно.
Обратите внимание, что когда я установил в качестве size A5, мой принтер остался на значении Letter, а размер A5 полностью помещается в размер Letter, что создаёт впечатление наличия полей, несмотря на то, что они берутся не из параметра margin.
@page { size: A5 portrait; margin: 0; }:

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

Из экспериментов выяснилось, что Chrome следует правилу @page, только если Margin имеет значение Default. Как только вы измените Margin в диалоге печати, результат станет производным от физического размера бумаги и выбранных полей.
@page { size: A5 portrait; margin: 0; }:


Даже если выбрать размер @page, полностью помещающийся в физический размер бумаги, margin всё равно важен. Вот пример квадрата 5x5 без полей и квадрата 5x5 с полями. Размер элемента <html> ограничен общим размером @page и полей.
@page { size: 5in 5in; margin: 0; }:

@page { size: 5in 5in; margin: 1in; }:

Я проделал все эти тесты не потому. что хочу печатать на бумаге A5 или 5x5, а потому, что мне довольно долго пришлось разбираться, что же такое @page. Теперь я уверен, что всегда нужно использовать Letter с margin 0.
Печать @media
Существует медиа-запрос print, позволяющий записывать стили, применяющиеся только во время печати. Мои страницы генераторов часто содержат заголовок, разные опции и вспомогательный текст для пользователя; очевидно, что они не должны выводиться на печать, поэтому сюда добавляется display:none для этих элементов.
/* Обычные стили, отображаемые при подготовке документа */ header { display: block; } @media print { /* Пропадают при печати документа */ header { display: none; } }


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

Я всегда устанавливаю @page margin: 0, потому что предпочту обрабатывать поля в самих элементах DOM. Когда я пробовал устанавливать @page margin: 0.5in, то иногда получались двойные поля, сжимавшие содержимое и делавшие его меньше ожидаемого, и мой одностраничный дизайн частично переносился на вторую страницу.
Если бы я хотел использовать поля @page, то содержимое страницы нужно было бы выстроить вплоть до границ DOM; мне сложнее думать об этом и это сложнее отображать при предпросмотре перед печатью. Мне проще помнить, что <html> занимает всю физическую бумагу, а мои поля находятся внутри DOM, а не за его пределами.
@page { size: Letter portrait; margin: 0; } html, body { width: 8.5in; height: 11in; }
При работе с генераторами многостраничной печати стоит создавать отдельный элемент DOM для каждой страницы. Так как нескольких <html> и <body> быть не может, нам понадобится другой элемент. Мне нравится <article>. Его можно использовать всегда, даже для одностраничных генераторов.
Так как каждый <article> обозначает одну страницу, мне не нужны поля и отступы в <html> и <body>. Мы размещаем логику на один шаг дальше — проще сделать так, чтобы article занимал физическую страницу целиком и поместить поля внутрь него.
@page { size: Letter portrait; margin: 0; } html, body { margin: 0; } article { width: 8.5in; height: 11in; }
При добавлении полей в article я пользуюсь не свойством margin, а padding. Причина в том, что margin выходит наружу элемента в рамочной модели (box model). Если ��спользовать margin в 0.5in, то нужно установить article на 7.5×10, чтобы article плюс 2×margin равнялось 8.5×11. А если вам захочется изменить эти поля, то придётся и изменить и другие размерности.
padding находится внутри элемента, поэтому можно задать размер article 8.5×11 с отступом в 0.5in, и все элементы внутри article останутся на странице.
Разбираться в размерностях элементов становится сильно проще, если задать box-sizing: border-box. При этом внешние размерности article фиксированы при настройке внутреннего отступа. Вот мой фрагмент кода:
html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; }
Теперь соединим всё вместе:
@page { size: Letter portrait; margin: 0; } html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } html, body { margin: 0; } article { width: 8.5in; height: 11in; padding: 0.5in; }

Позиционирование элементов
После настройки article и margin вы можете настраивать пространство внутри article, как вам угодно. Создавайте дизайн документа при помощи любого HTML/CSS, который кажется вам подходящим для проекта. Иногда для этого нужно размещать элементы при помощи flex или grid, потому что вам дали определённую свободу действий с результатом. Иногда для этого нужно создавать квадраты конкретного размера, чтобы они поместились на конкретной марке бумаги для стикеров. Иногда для этого приходится позиционировать всё в абсолютных величинах, вплоть до миллиметра, потому что пользователю нужно пропустить через принтер специальный кусок бумаги, чтобы напечатать поверх него ваши данные, а вы не имеете контроля над этим куском бумаги.
Я не буду здесь давать рекомендации по написанию HTML, поэтому вы должны уметь делать это сами. Могу только сказать, что нужно помнить, что вы имеете дело с ограниченным пространством бумаги, а не с окном браузера, которое можно скроллить и зумить на любую длину или масштаб. Если документ будет содержат произвольное количество элементов, будьте готовы к реализации пагинации созданием дополнительных <article>.
Многостраничные документы с повторяющимися элементами
Многие из создаваемых мной генераторов печати содержат табличные данные, например, счёт-фактуру с пунктами в виде строк. Если ваша <table> большая и переходит на следующую страницу, то браузер автоматически дублирует <thead> в начале каждой страницы.
<table> <thead> <tr> <th>Sample text</th> <th>Sample text</th> </tr> </thead> <tbody> <tr><td>0</td><td>0</td></tr> <tr><td>1</td><td>1</td></tr> <tr><td>2</td><td>4</td></tr> ... </tbody> </table>

Это здорово, если вы просто печатаете <table> без лишних украшательств, но в во многих реальных ситуациях всё не так просто. Воссоздаваемый мной документ часто имеет печатный заголовок наверху каждой страницы, сноску внизу и другие специальные элементы, которые должны повторяться на каждой странице. Если просто печатать одну длинную таблицу на все страницы, то вы практич��ски никак не сможете размещать другие элементы выше, ниже и вокруг на промежуточных страницах.
Поэтому я генерирую страницы при помощи Javascript, разбивая таблицу на несколько более мелких. В целом я использую такой подход:
Обращаюсь с элементами
<article>как с одноразовыми и готовлюсь перегенерировать их в любой момент из объектов в памяти. Весь ввод и настройки пользователя должны происходить в отдельном блоке заголовка/опций, вне всех article.Пишу функцию
new_page, создающую новый элемент article со всеми необходимыми повторяющимися заголовками/сносками и так далее.Пишу функцию
render_pages, создающую article из базовых данных, вызываюnew_pageкаждый раз. когда она заполняет предыдущую article. Обычно я используюoffsetTop, чтобы контролировать, когда контент уходит слишком по странице, но совершенно точно можно использовать более умные методики для идеального размещения на каждой странице.Вызываю
render_pagesпри каждом изменении базовых данных.
function delete_articles() { for (const article of Array.from(document.getElementsByTagName("article"))) { document.body.removeChild(article); } } function new_page() { const article = document.createElement("article"); article.innerHTML = ` <header>...</header> <table>...</table> <footer>...</footer> `; document.body.append(article); return article; } function render_pages() { delete_articles(); let page = new_page(); let tbody = page.query("table tbody"); for (const line_item of line_items) { // Обычно я подбираю это пороговое значение экспериментально, но, вероятно, можно // задать что-то более строгое. if (tbody.offsetTop + tbody.offsetParent.offsetTop > 900) { page = new_page(); tbody = page.query("table tbody"); } const tr = document.createElement("tr"); tbody.append(tr); // ... } }
Обычно неплохо добавлять на страницы счётчик «страница X из Y». Так как количество страниц неизвестно, пока не будут сгенерированы все страницы, этого нельзя сделать в цикле for. В конце я вызываю следующую функцию:
function renumber_pages() { let pagenumber = 1; const pages = document.getElementsByTagName("article"); for (const page of pages) { page.querySelector(".pagenumber").innerText = pagenumber; page.querySelector(".totalpages").innerText = pages.length; pagenumber += 1; } }
Портретный/альбомный режим
Я показал, что правило @page помогает настроить стандартные параметры печати браузера, но при желании пользователь может их переопределить. Если вы установите в @page портретный режим, а пользователь выберет альбомный, то структура и пагинация могут выглядеть неправильно, особенно если вы жёстко указываете все пороговые значения страниц.
Можно учесть это, создав отдельные элементы <style> для портретного и альбомного режимов и переключаясь между ними при помощи Javascript. Возможно, есть и более хороший способ сделать это, но @-правила наподобие @page ведут себя иначе, чем обычные CSS-свойства, так что я не уверен. Также нужно хранить какую-то переменную, которая позволит функции render_pages работать правильно.
Ещё можно перестать задавать пороговые значения жёстко, но тогда мне придётся следовать своей собственной рекомендации.
<select onchange="return page_orientation_onchange(event);"> <option selected>Portrait</option> <option>Landscape</option> </select>
<style id="style_portrait" media="all"> @page { size: Letter portrait; margin: 0; } article { width: 8.5in; height: 11in; } </style> <style id="style_landscape" media="not all"> @page { size: Letter landscape; margin: 0; } article { width: 11in; height: 8.5in; } </style>
let print_orientation = "portrait"; function page_orientation_onchange(event) { print_orientation = event.target.value.toLocaleLowerCase(); if (print_orientation == "portrait") { document.getElementById("style_portrait").setAttribute("media", "all"); document.getElementById("style_landscape").setAttribute("media", "not all"); } if (print_orientation == "landscape") { document.getElementById("style_landscape").setAttribute("media", "all"); document.getElementById("style_portrait").setAttribute("media", "not all"); } render_printpages(); } function render_printpages() { if (print_orientation == "portrait") { // ... } else { // ... } }
Источник данных
Существует пара способов переноса данных на страницы. Иногда я упаковываю все данные в параметры URL, чтобы Javascript просто делал const url_params = new URLSearchParams(window.location.search);, а затем несколько операций вида url_params.get("title"). У такого подхода есть следующие преимущества:
Страница загружается очень быстро.
Легко выполнять отладку и экспериментировать, меняя URL.
Генератор работает офлайн.
Но у него есть и минусы:
URL становятся очень длинными и хаотичными, люди не могут удобным образом отправлять их по почте. См. примеры ссылок в начале этой статьи.
Если URL отправляется через электронную почту, то данные «фиксируются», даже если исходная запись в базе данных позже изменяется.
У браузеров есть ограничение на длину URL. Оно достаточно большое, но не бесконечное и может быть разным в клиентах.
Ин��гда вместо этого я использую Javascript для получения записей базы данных через API, поэтому параметры URL содержат только первичный ключ и иногда параметр режима.
У этого есть свои плюсы:
URL гораздо короче.
Данные всегда актуальны.
и минусы:
Пользователю приходится ждать, прежде чем данные будут получены.
Нужно писать код.
Иногда я задаю для article contenteditable, чтобы пользователь мог вносить небольшие изменения перед печатью. Кроме того, я люблю использовать реальные действующие чекбоксы, на которые можно нажать перед печатью. Эти функции повышают удобство, но в большинстве случаев лучше сделать так, чтобы пользователь сначала изменил исходную запись в базе данных. Кроме того, это ограничивает возможность работы с элементами article как с одноразовыми.
Шпаргалка по самому важному
<!DOCTYPE html> <html> <style> @page { size: Letter portrait; margin: 0; } html { box-sizing: border-box; } *, *:before, *:after { box-sizing: inherit; } html, body { margin: 0; background-color: lightblue; } header { background-color: white; max-width: 8.5in; margin: 8px auto; padding: 8px; } article { background-color: white; padding: 0.5in; width: 8.5in; height: 11in; /* Для центрирования страницы на экране в процессе подготовки */ margin: 8px auto; } @media print { html, body { background-color: white !important; } body > header { display: none; } article { margin: 0 !important; } } </style> <body> <header> <p>Текст подсказки с объяснением задачи этого генератора.</p> <p><button onclick="return window.print();">Print</button></p> </header> <article> <h1>Sample page 1</h1> <p>sample text</p> </article> <article> <h1>Sample page 2</h1> <p>sample text</p> </article> </body> </html>
