介绍
在工作中,我经常做的一件事情是在HTML中编写打印生成器,以重建和替换公司传统上手写在纸张上或在Excel中完成的表单。这使得公司能够转向新的基于Web的工具,在这些工具中,表单由来自我们数据库的URL参数自动填充,同时获得每个人都熟悉的相同物理输出。
本文解释了一些控制网页在打印时外观的CSS基础知识,以及我学到的一些技巧和窍门,希望能对你有所帮助。
示例文件
以下是一些示例页面生成器,以建立一些上下文,并可能增加一些可信度。
我将首先承认这些页面有点丑陋,可能需要更多的精雕细琢。但它们能够完成工作,而且我仍然受雇于公司。
@page
CSS有一个叫做@page
的规则,它通知浏览器您网站的打印首选项。通常,我使用以下方式:
@page {
size: Letter portrait;
margin: 0;
}
我将在稍后关于边距的部分解释为什么选择 margin: 0
。您应根据您与度量制度的关系适当使用Letter或A4。
设置@page的大小和边距与设置
或元素的宽度、高度和边距不同。@page超出了DOM —— 它包含了DOM。在Web上,您的元素受到屏幕边缘的限制,但在打印时,它受到@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>示例文本</h1>
<p>示例文本</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仅在将Margin设置为默认值时才遵循@page规则。一旦您在打印对话框中更改了Margin,您的输出将成为您的物理纸张大小和所选Margin的产物。
@page { size: A5 portrait; margin: 0; }
:
即使您选择的@page大小完全适合您的物理纸张,margin
仍然很重要。在这里,我使用没有边距的5x5正方形和带有边距的5x5正方形。 <html>
元素的大小受限于@page大小和边距的组合。
@page { size: 5in 5in; margin: 1in; }
:
我进行了所有这些测试,不是因为我期望在A5或5x5纸张上打印,而是因为我花了一段时间才弄清楚@page到底是什么。现在我对始终使用Letter并设置边距为0非常自信。
@media print
有一个名为print
的媒体查询,您可以在其中编写仅在打印时应用的样式。我的生成器页面通常包含标题、一些选项和一些用户帮助文本,显然这些内容在打印时不应该显示,这就是您在这里在这些元素上添加display:none
的地方。
/* 准备文档时显示的正常样式 */
header {
display: block;
}
@media print {
/* 打印文档时消失的样式 */
header {
display: none;
}
}
宽度、高度、边距和填充
要想在不太折磨电脑的情况下获得您想要的边距,您需要了解一些关于盒模型的知识。
我始终将@page的 margin: 0
是因为我宁愿在DOM元素上处理边距。当我尝试使用@page的 margin: 0.5in
时,我经常会意外地得到双倍边距,使得内容变得比我预期的更小,我的单页设计溢出到第二页。
如果我想使用@page的margin,则实际页面内容需要完全放在DOM的边缘上,这对我来说更难思考,也更难在打印之前进行预览。我更容易记住<html>
占据整个物理纸张,而我的边距在DOM内部而不是DOM之外。
@page {
size: Letter portrait;
margin: 0;
}
html,
body {
width: 8.5in;
height: 11in;
}
对于多页打印生成器,您需要一个代表每页的单独DOM元素。由于您不能有多个<html>
或<body>
,所以您需要另一个元素。我喜欢<article>
。即使对于单页生成器,您也可以始终使用文章。
由于每个<article>
代表一页,我不希望在<html>
或<body>
上有任何边距或填充。我们将逻辑推得更远 —— 对我来说,让文章占据整个物理页面并在其中放置边距更容易。
@page {
size: Letter portrait;
margin: 0;
}
html,
body {
margin: 0;
}
article {
width: 8.5in;
height: 11in;
}
当我谈论在我的文章中添加边距时,我不使用 margin
属性,而是使用 padding
。这是因为 margin
在盒模型中将元素外部周围。如果您使用0.5in的 margin
,您将不得不将文章设置为7.5×10,以便文章加上2×margin等于8.5×11。如果您想调整边距,您将不得不调整其他尺寸。
相反, padding
放在元素的内部,因此我可以将文章定义为8.5×11,并在其中添加0.5in的 padding
,而文章内部的所有元素都将保持在页面上。
当您设置box-sizing: border-box
时,关于元素尺寸的许多直觉都会变得更简单。它使得文章的外部尺寸被锁定,同时调整内部填充。这是我的代码片段:
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;
}
元素定位
一旦您设置了文章和边距,文章内部的空间就是您随意使用的空间。根据项目的需要,使用任何您认为适合的HTML/CSS设计文档。有时这意味着使用flex或grid来布局元素,因为您对输出有一定的灵活性。有时这意味着创建特定大小的正方形以适应某种品牌的贴纸纸张。有时这意味着绝对定位几乎所有内容,因为用户需要将一个特殊的预先标记的纸张通过打印机,然后将您的数据放在其上,而您无法控制该特殊纸张。
这里为了给您提供一般的HTML编写教程,因此您需要自己能够做到这一点。我只能说,要注意您正在处理的是一张纸的有限空间,而不是可以滚动和缩放到任何长度或比例的浏览器窗口。如果您的文档将包含任意数量的项目,请准备通过创建更多的 <article>
进行分页。
带有重复元素的多页文档
我编写的许多打印生成器包含表格数据,比如一张包含许多行项目的发票。如果您的 <table>
足够大以至于需要跨页,浏览器将自动在每页顶部重复 <thead>
。
<table>
<thead>
<tr>
<th>示例文本</th>
<th>示例文本</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>
元素视为可丢弃的,并随时准备从内存中的对象重新生成它们。所有用户输入和配置都应在单独的标题/选项框中进行,位于文章之外。 - 编写一个名为
new_page
的函数,它创建一个带有必要重复头/页脚等的新文章元素。 - 编写一个名为
render_pages
的函数,它从基本数据创建文章,每次填满上一页时都调用new_page
。我通常使用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) {
// I usually pick this threshold by experimentation but you can probably
// do something more rigorously correct.
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页" 的计数器。由于直到生成所有页面后才知道页面数,因此无法在循环中完成此操作。我在最后调用类似这样的函数:
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
规则如何帮助指定浏览器的默认打印设置,但是如果用户想要覆盖它们,您的布局和分页可能会出现问题,特别是如果您在硬编码任何页面阈值。
您可以通过为纵向和横向分别创建独立的 <style>
元素,并使用JavaScript在它们之间切换来适应用户的偏好。也许有更好的方法来做到这一点,但是像 @page
这样的at-rule的行为与普通CSS属性不同,所以我不确定。您还应该保存一些变量,可以帮助您的 render_pages
函数做正确的事情。
您还可以停止硬编码阈值,但那我就得听我的建议了。
<select onchange="return page_orientation_onchange(event);">
<option selected>纵向</option>
<option>横向</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要短得多。
- 数据始终是最新的。
也有一些缺点:
- 用户必须等待一秒钟,直到数据被获取。
- 您必须编写更多的代码。
有时候我会在文章上设置 contenteditable
,以便用户在打印之前可以做出小的更改。我还喜欢使用真正的、活动的复选框输入,用户可以在打印之前单击。这些功能增加了一些方便性,但在大多数情况下,让用户先更改数据库中的源记录会更明智。此外,它们会限制您将文章元素视为可丢弃的能力。
基本要点速查表
<!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;
/* For centering the page on the screen during preparation */
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();">打印</button></p>
</header>
<article>
<h1>示例页面 1</h1>
<p>示例文本</p>
</article>
<article>
<h1>示例页面 2</h1>
<p>示例文本</p>
</article>
</body>
</html>