项目

QuestPDF 入门指南

期望内容

本教程将向您介绍如何使用 QuestPDF 库实现一个基本的发票文档。它会讨论架构概念,然后展示如何准备数据层,并最后演示如何使用各种元素来构建文档结构。完成此教程后,您将获得能够生成类似下图所示的完整、页面感知的发票的代码。

发票账单

提示: 可以访问 这个 GitHub 存储库 下载、分析和编译代码。

安装

该库作为 NuGet 包分发。您可以像安装其他 NuGet 包一样从您的 IDE 中安装它,搜索关键字 QuestPDF。有关包详情,请访问 此网页

// Package Manager
Install-Package QuestPDF

// .NET CLI
dotnet add package QuestPDF

// .csproj 文件中的包引用
<PackageReference Include="QuestPDF" Version="2024.3.5" />

实现层次结构

PDF 生成过程涉及三个主要应用程序层的工作:

  1. 文档模型:描述 PDF 文档内容的一组类。通常情况下,它们只是简单的 POCO 类,其中不含任何业务逻辑。

  2. 数据源:层面上,您的领域实体被映射到文档模型中。这通常通过创建一个单独的类来实现,该类与持久性抽象进行通信,然后将数据转换成所需的格式。

  3. 模板:描述如何呈现信息并将其转换为 PDF 文件的呈现层。 QuestPDF 的方法有所不同:该库为您提供了一个特殊的文档布局引擎。通过使用简单但高度可组合的元素,您可以轻松设计复杂的布局——所有这些都通过强大的链式 API 实现。

文档模型层

在处理新 PDF 文档时,考虑其内容以及应包含哪些信息。这有助于设计合适的模型结构。这次我们需要传递基础发票信息、卖家和客户地址、订单项目列表,最后是可选的评论。

public class InvoiceModel
{
    public int InvoiceNumber { get; set; }
    public DateTime IssueDate { get; set; }
    public DateTime DueDate { get; set; }

    public Address SellerAddress { get; set; }
    public Address CustomerAddress { get; set; }

    public List<OrderItem> Items { get; set; }
    public string Comments { get; set; }
}

public class OrderItem
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

public class Address
{
    public string CompanyName { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public object Email { get; set; }
    public string Phone { get; set; }
}

数据源层

一旦定义了模型,就需要创建一个数据源类,该类连接到持久层,准备和转换数据。尽管该层没有限制,您完全控制其实现,但仍有一些模式和实践值得遵循。

首先,在数据源类中,您可以定义所有必要的业务逻辑。简单的操作可以放在模板层(下一章将讨论)中,但更复杂的计算应保留在这里或不适合的服务中,以防止业务逻辑泄漏到域之外。

如果预期有多个具有相似内容的文档,例如相同的标题,定义一个共享模型和相应的填充方法。QuestPDF 遵循 “不要重复自己”(Don't Repeat Yourself,DRY)原则,提供了强大的组件概念。您可以定义内容元素,注入数据模型以生成适当的内容,甚至可以使用插槽自定义它。这些概念类似于其他流行库,如 VueAngular

本教程主要关注布局结构的准备。因此,所有必要的数据都是随机生成的。

提示: 为了改善工作流程,可以使用各种辅助方法轻松生成假数据。所有这些方法都在静态 Placeholders 类中可用。这样,无需实现真实的数据源,就可以轻松原型化文档结构。

using QuestPDF.Helpers;

public static class InvoiceDocumentDataSource
{
    private static Random Random = new Random();

    public static InvoiceModel GetInvoiceDetails()
    {
        var items = Enumerable
            .Range(1, 8)
            .Select(i => GenerateRandomOrderItem())
            .ToList();

        return new InvoiceModel
        {
            InvoiceNumber = Random.Next(1_000, 10_000),
            IssueDate = DateTime.Now,
            DueDate = DateTime.Now + TimeSpan.FromDays(14),

            SellerAddress = GenerateRandomAddress(),
            CustomerAddress = GenerateRandomAddress(),

            Items = items,
            Comments = Placeholders.Paragraph()
        };
    }

    private static OrderItem GenerateRandomOrderItem()
    {
        return new OrderItem
        {
            Name = Placeholders.Label(),
            Price = (decimal) Math.Round(Random.NextDouble() * 100, 2),
            Quantity = Random.Next(1, 10)
        };
    }

    private static Address GenerateRandomAddress()
    {
        return new Address
        {
            CompanyName = Placeholders.Name(),
            Street = Placeholders.Label(),
            City = Placeholders.Label(),
            State = Placeholders.Label(),
            Email = Placeholders.Email(),
            Phone = Placeholders.PhoneNumber()
        };
    }
}

模板层

文档生成的最重要方面是设计和实现其布局。QuestPDF 提供多种工具来实现所需结果。本教程讨论了一些最重要的概念。有关特定元素的更多信息,请访问 API 参考

构建页面结构

实现从定义一个新的类开始,该类实现了 IDocument 接口。该接口包含两个方法:GetMetadata()Compose()。前者用于提供关于作者、关键词、DPI 设置等的基本文档信息,后者则提供一个容器来放置所有内容。

public interface IDocument
{
    DocumentMetadata GetMetadata();
    DocumentSettings GetSettings();
    void Compose(IDocumentContainer container);
}

提示: 本教程使用默认元数据配置。如果您想覆盖它,请创建并返回具有适当配置的新 Metadata 对象。大多数属性都很直观。

下面的类实现了基本的文档结构。请注意不同链式 API 调用是如何串联在一起的。每次调用都会创建一个具有相应样式、视觉效果、大小或对齐约束等的单独容器。因此,方法的顺序非常重要,交换元素可能会产生不同的结果。

大多数元素都是单一子项的简单容器,此时使用方法链来描述文档内容。然而,也有一些更高级的元素,它们提供多个插槽来填充。

using QuestPDF.Drawing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

public class InvoiceDocument : IDocument
{
    public InvoiceModel Model { get; }

    public InvoiceDocument(InvoiceModel model)
    {
        Model = model;
    }

    public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
    public DocumentSettings GetSettings() => DocumentSettings.Default;

    public void Compose(IDocumentContainer container)
    {
        container
            .Page(page =>
            {
                page.Margin(50);

                page.Header().Height(100).Background(Colors.Grey.Lighten1);
                page.Content().Background(Colors.Grey.Lighten3);
                page.Footer().Height(50).Background(Colors.Grey.Lighten1);
            });
    }
}

Page 元素有三个插槽:HeaderContentFooter。此外,它们还有额外的规则:

  • Header 始终位于顶部。
  • Footer 始终位于底部。
  • Content 占据剩余空间。

到目前为止,我们已经构建了一个非常简单的页面,每个部分都有不同的颜色或大小:

step-scaffolding

头部实现

本章介绍了一些重要的布局元素:RowColumn。在讨论它们之前,先看新的代码示例。首先,创建文档时,我们期望它包含多个部分,因此代码量将显著增加。

为了保持代码整洁且易于维护,可以为每个部分创建额外的方法。一般原则是使用简单的布局结构组合,每个方法对应一个结构。大部分 API 调用都有特殊重载,用于 1) 链式调用和 2) 作为参数传递方法。

public class InvoiceDocument : IDocument
{
    /* code omitted */

    public void Compose(IDocumentContainer container)
    {
        container
            .Page(page =>
            {
                page.Margin(50);

                page.Header().Element(ComposeHeader);
                page.Content().Element(ComposeContent);


                page.Footer().AlignCenter().Text(x =>
                {
                    x.CurrentPageNumber();
                    x.Span(" / ");
                    x.TotalPages();
                });
            });
    }

    void ComposeHeader(IContainer container)
    {
        var titleStyle = TextStyle.Default.FontSize(20).SemiBold().FontColor(Colors.Blue.Medium);

        container.Row(row =>
        {
            row.RelativeItem().Column(column =>
            {
                column.Item().Text($"Invoice #{Model.InvoiceNumber}").Style(titleStyle);

                column.Item().Text(text =>
                {
                    text.Span("Issue date: ").SemiBold();
                    text.Span($"{Model.IssueDate:d}");
                });

                column.Item().Text(text =>
                {
                    text.Span("Due date: ").SemiBold();
                    text.Span($"{Model.DueDate:d}");
                });
            });

            row.ConstantItem(100).Height(50).Placeholder();
        });
    }

    void ComposeContent(IContainer container)
    {
        container
            .PaddingVertical(40)
            .Height(250)
            .Background(Colors.Grey.Lighten3)
            .AlignCenter()
            .AlignMiddle()
            .Text("Content").FontSize(16);
    }
}

上面的代码生成如下结果:

头部

内容实现

在文档生成的世界中,预期单个文档有多页。QuestPDF 库假设某些元素应该跨页面重复,例如头部和页脚。此外,它还提供了一种很好的机制来支持分页内容。通常不希望在任何地方拆分内容,通常希望明确指定何时(如果需要)发生分割。

public class InvoiceDocument : IDocument
{
    /* code omitted */

    void ComposeContent(IContainer container)
    {
        container.PaddingVertical(40).Column(column =>
        {
            column.Spacing(5);

            column.Item().Element(ComposeTable);

            if (!string.IsNullOrWhiteSpace(Model.Comments))
                column.Item().PaddingTop(25).Element(ComposeComments);
        });
    }

    void ComposeTable(IContainer container)
    {
        container
            .Height(250)
            .Background(Colors.Grey.Lighten3)
            .AlignCenter()
            .AlignMiddle()
            .Text("Table").FontSize(16);
    }

    void ComposeComments(IContainer container)
    {
        container.Background(Colors.Grey.Lighten3).Padding(10).Column(column =>
        {
            column.Spacing(5);
            column.Item().Text("Comments").FontSize(14);
            column.Item().Text(Model.Comments);
        });
    }
}

在代码中,内容结构已准备就绪。请注意,评论部分是条件显示的:

内容部分

表格生成

接下来,我们将介绍 Table 元素。此元素允许您放入多个单元格。

可以使用 Row(X)Column(X) 方法指定单元格的确切位置。然而,默认情况下,位置也可以由算法自动确定。每个单元格还可以占用多行或多列。要指定这种行为,请使用 RowSpan(X)ColumnSpan(X) 方法。

以下是分三步实现表格的简单步骤:

  1. 步骤 1 定义列数和大小。类似于 Row 元素,你可以创建固定宽度和相对宽度的列。
  2. 步骤 2 实现表格的表头。这是一个特殊部分:当表格跨多页时,表头内容会出现在每一页上。
  3. 步骤 3 使用 foreach 循环遍历所有产品,然后为每个产品生成一系列单元格。
public class InvoiceDocument : IDocument
{
    /* code omitted */

    void ComposeTable(IContainer container)
    {
        container.Table(table =>
        {
            // step 1
            table.ColumnsDefinition(columns =>
            {
                columns.ConstantColumn(25);
                columns.RelativeColumn(3);
                columns.RelativeColumn();
                columns.RelativeColumn();
                columns.RelativeColumn();
            });

            // step 2
            table.Header(header =>
            {
                header.Cell().Element(CellStyle).Text("#");
                header.Cell().Element(CellStyle).Text("Product");
                header.Cell().Element(CellStyle).AlignRight().Text("Unit price");
                header.Cell().Element(CellStyle).AlignRight().Text("Quantity");
                header.Cell().Element(CellStyle).AlignRight().Text("Total");

                static IContainer CellStyle(IContainer container)
                {
                    return container.DefaultTextStyle(x => x.SemiBold()).PaddingVertical(5).BorderBottom(1).BorderColor(Colors.Black);
                }
            });

            // step 3
            foreach (var item in Model.Items)
            {
                table.Cell().Element(CellStyle).Text(Model.Items.IndexOf(item) + 1);
                table.Cell().Element(CellStyle).Text(item.Name);
                table.Cell().Element(CellStyle).AlignRight().Text($"{item.Price}$");
                table.Cell().Element(CellStyle).AlignRight().Text(item.Quantity);
                table.Cell().Element(CellStyle).AlignRight().Text($"{item.Price * item.Quantity}$");

                static IContainer CellStyle(IContainer container)
                {
                    return container.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(5);
                }
            }
        });
    }

    /* code omitted */
}

表格生成

地址组件

掌握最后一个技能是代码重用和实现。在创建多个不同的文档类型时,通常它们共享共同的部分,例如带有公司 logo 的头部。有时,你的页面可能有多个具有相同结构但信息不同的部分。此外,有些部分可能非常复杂,应该提取到单独的类中。

为了妥善解决上述所有场景,请使用组件方法。这样,你可以创建独立的、项目特定的元素,便于重用和维护。实现组件从 IComponent 接口开始:

public interface IComponent
{
    void Compose(IContainer container);
}

创建组件与将代码提取到单独方法非常相似。这次,分离更加明显,因为你将代码移动到了新的类和文件中,并且可以轻松地为组件提供参数。

public class AddressComponent : IComponent
{
    private string Title { get; }
    private Address Address { get; }

    public AddressComponent(string title, Address address)
    {
        Title = title;
        Address = address;
    }

    public void Compose(IContainer container)
    {
        container.Column(column =>
        {
            column.Spacing(2);

            column.Item().BorderBottom(1).PaddingBottom(5).Text(Title).SemiBold();

            column.Item().Text(Address.CompanyName);
            column.Item().Text(Address.Street);
            column.Item().Text($"{Address.City}, {Address.State}");
            column.Item().Text(Address.Email);
            column.Item().Text(Address.Phone);
        });
    }
}

下面的代码展示了如何使用新实现的组件:

public class InvoiceDocument : IDocument
{
    /* code omitted */

    void ComposeContent(IContainer container)
    {
        container.PaddingVertical(40).Column(column =>
        {
            column.Spacing(5);

            column.Item().Row(row =>
            {
                row.RelativeItem().Component(new AddressComponent("From", Model.SellerAddress));
                row.ConstantItem(50);
                row.RelativeItem().Component(new AddressComponent("For", Model.CustomerAddress));
            });

            column.Item().Element(ComposeTable);

            var totalPrice = Model.Items.Sum(x => x.Price * x.Quantity);
            column.Item().AlignRight().Text($"Grand total: {totalPrice}$").FontSize(14);

            if (!string.IsNullOrWhiteSpace(Model.Comments))
                column.Item().PaddingTop(25).Element(ComposeComments);
        });
    }

    /* code omitted */
}

step-final

文档生成

所有部分准备就绪后,生成过程很简单:

using System.IO;
using QuestPDF.Drawing;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;

static void Main(string[] args)
{
    var model = InvoiceDocumentDataSource.GetInvoiceDetails();
    var document = new InvoiceDocument(model);
    document.GeneratePdfAndShow();

    // document.GeneratePdf("invoice.pdf");
}

提示:你可以通过访问 这个 GitHub 存储库 下载、分析和编译代码。

复杂示例

想要查看使用了大部分可用功能的更高级示例吗?请查看 存储库 。它包含一个样本报告:

complex

在本文档中