Webhek

将网页漂亮的打印到纸上的CSS

    目录
  1. 简介
  2. 几个示例
  3. @page
  4. @media print
  5. 宽度, 高度, margin, 和 padding
  6. 元素位置
  7. 带有重复元素的多页文档
  8. 纵向/横向模式
  9. 数据来源
  10. 速查表

简介 (§)

在工作中,我经常做的一件事就是用 HTML 编写打印生成器,以重新创建和替换公司传统上在纸上或 Excel 中手写的表单。这样,公司就可以使用新的网络工具,通过数据库中的 URL 参数自动填写表格,同时获得大家熟悉的物理输出。

本文介绍了控制网页打印效果的 CSS 基础知识,以及我学到的一些技巧和窍门,或许对你有所帮助。

几个示例 (§)

下面是一些页面示例,包括一些背景,还有一些 logo。

我会第一个承认这些页面有点难看,还需要进一步打磨。但它们能完成工作,而且我还在改进。

发票样式

带侧边栏输入的封面页

可编辑内容的封面页

二维码生成器

@page (§)

CSS 有一个名为 @page 的规则,它可以将网站的打印偏好告知浏览器。通常,我使用

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

我将在后面有关页边距的章节中解释为什么选择 margin: 0。根据您与公制系统的关系,您应该使用 Letter A4

设置 @page 的大小和边距与设置 <html><body> 元素的宽度、高度和边距不同。@page 超越了 DOM - 它包含了 DOM。在网页上,<html> 元素的边界是屏幕的边缘,但在打印时,它的边界是 @page

@page 控制的设置或多或少与按下 Ctrl+P 时在浏览器打印对话框中获得的设置一致。

下面是我用来做一些实验的示例文件:

<!DOCTYPE html>
<html>
<style>
@page
{
    /* see below for each experiment */
}
html
{
    width: 100%;
    height: 100%;
    background-color: lightblue;

    /* grid by 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尺寸并不能将该尺寸的纸张放入打印机的进纸托盘。这部分需要您自己完成。

请注意,当我将尺寸设置为 A5 时,我的打印机保持在 Letter 尺寸,而 A5 尺寸完全符合 Letter 尺寸,这就给人一种页边距的感觉,尽管它不是来自页margin设置。

@page { size: A5 portrait; margin: 0; }:

但是,如果我告诉打印机我装的是真正的 A5 纸张,那么它看起来就和预期的一样。

根据我的实验,Chrome 浏览器只有在边距设置为默认的情况下才会遵循 @page 规则。一旦你在打印对话框中更改了页边距,你的输出结果就会变成物理纸张尺寸和所选页边距。

@page { size: A5 portrait; margin: 0; }:

即使你选择的 @page 大小完全适合你的实体纸张,页边距仍然很重要。在这里,我制作了一个不带边距的 5x5 正方形和一个带边距的 5x5 正方形。<html> 元素的大小受 @page 大小和页边距margin的限制。

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

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

我做这些测试并不是因为我希望在 A5 或 5x5 纸张上打印,而是因为我花了一段时间才弄明白 @page 到底是什么。现在,我非常有信心始终使用页边距为 0 的 Letter 纸张。

@media print (§)

有一个名为 print 的 media query ,在这里你可以编写只在打印时应用的样式。我的生成器页面通常包含一个页眉、一些选项和一些帮助用户的文本,这些显然不应该在打印时显示出来,所以这里就需要在这些元素上添加 display:none

/* Normal styles that appear while you are preparing the document */
header
{
    display: block;
}

@media print
{
    /* Disappear when you are printing the document */
    header
    {
        display: none;
    }
}

宽度, 高度, margin, 和 padding (§)

你需要对box model有一定的了解,这样才能获得你想要的边距,而不会让电脑太费力。

我之所以总是设置 @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 时,我使用的不是 margin 属性,而是 padding。这是因为在 box model中,margin 位于元素的外部和周围。如果使用 0.5 英寸的边距,就必须将文章设置为 7.5×10,这样文章加上 2×margin 就等于 8.5×11。

相反,padding 位于元素的内侧,因此我可以将文章定义为 8.5×11 并加上 0.5 英寸的 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 设计文档。有时,这意味着使用柔性或网格来布局元素,因为你在输出时有一定的回旋余地。有时,这意味着要创建特定大小的正方形,以适合特定品牌的贴纸。有时,这意味着要将所有内容都精确到毫米,因为用户需要将一张特殊的预标签纸送入打印机,才能将你的数据放在上面,而你却无法控制那张特殊的纸。

我在这里并不是要教你如何编写 HTML,所以你需要具备编写 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 生成页面,将表格分割成几个较小的表格。一般的做法是这样的:

  1. <article> 元素视为一次性元素,并随时准备从内存中的对象重新生成它们。所有用户输入和配置都应在文章之外的单独页眉/选项框中进行。
  2. 编写一个名为 new_page 的函数,用于创建一个新的文章元素,并在其中包含必要的重复页眉、页脚等。
  3. 编写一个名为 render_pages 的函数,从基础数据中创建文章,每次填满上一页时调用 new_page。我通常使用 offsetTop 来查看内容在页面上的位置,当然你也可以使用更智能的技术来使每一页都完美贴合。
  4. 当基础数据发生变化时,调用 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);
        // ...
    }
}

当基础数据发生变化时,调用 render_pages

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 更短。
  • 数据总是新鲜的。

也有缺点:

  • 用户在获取数据时需要等待一秒钟。
  • 必须编写更多代码。

有时,我会在文章上设置 contenteditable,这样用户就可以在打印前做一些小的修改。我还喜欢使用真实的复选框输入,用户可以在打印前点击。这些功能会带来一些便利,但在大多数情况下,让用户先更改数据库中的源记录会更明智。此外,这些功能还限制了将文章元素视为一次性元素的能力。

速查表 (§)

sample_cheatsheet.html

<!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>Some help text to explain the purpose of this generator.</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>