CSS для печати на бумаге
- пятница, 8 марта 2024 г. в 00:00:14
По работе я довольно часто занимаюсь созданием генераторов печати на HTML для воссоздания и замены форм, которые компания традиционно заполняла от руки на бумаге или в Excel. Это позволяет компании переходить на новые веб-инструменты, в которых форма автоматически заполняется по параметрам URL из нашей базы данных, создавая при этом тот же результат на бумаге, к которому все привыкли.
В этой статье я объясню основы CSS, управляющие внешним видом веб-страниц при печати, и дам пару советов, которые могут вам помочь в этом.
Вот несколько примеров генераторов страниц, чтобы вы поняли контекст.
Для начала я признаю, что эти страницы немного уродливые и их можно улучшить. Но они справляются с задачей и меня до сих пор не уволили.
Сопроводительная записка с вводом в боковую колонку
Сопроводительная записка с contenteditable
В 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.
Существует медиа-запрос 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>