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 生成过程涉及三个主要应用程序层的工作:
文档模型:描述 PDF 文档内容的一组类。通常情况下,它们只是简单的 POCO 类,其中不含任何业务逻辑。
数据源:层面上,您的领域实体被映射到文档模型中。这通常通过创建一个单独的类来实现,该类与持久性抽象进行通信,然后将数据转换成所需的格式。
模板:描述如何呈现信息并将其转换为 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
)原则,提供了强大的组件概念。您可以定义内容元素,注入数据模型以生成适当的内容,甚至可以使用插槽自定义它。这些概念类似于其他流行库,如 Vue
或 Angular
。
本教程主要关注布局结构的准备。因此,所有必要的数据都是随机生成的。
提示: 为了改善工作流程,可以使用各种辅助方法轻松生成假数据。所有这些方法都在静态
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
元素有三个插槽:Header
、Content
和 Footer
。此外,它们还有额外的规则:
Header
始终位于顶部。Footer
始终位于底部。Content
占据剩余空间。
到目前为止,我们已经构建了一个非常简单的页面,每个部分都有不同的颜色或大小:
头部实现
本章介绍了一些重要的布局元素:Row
和 Column
。在讨论它们之前,先看新的代码示例。首先,创建文档时,我们期望它包含多个部分,因此代码量将显著增加。
为了保持代码整洁且易于维护,可以为每个部分创建额外的方法。一般原则是使用简单的布局结构组合,每个方法对应一个结构。大部分 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 定义列数和大小。类似于
Row
元素,你可以创建固定宽度和相对宽度的列。 - 步骤 2 实现表格的表头。这是一个特殊部分:当表格跨多页时,表头内容会出现在每一页上。
- 步骤 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 */
}
文档生成
所有部分准备就绪后,生成过程很简单:
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 存储库 下载、分析和编译代码。
复杂示例
想要查看使用了大部分可用功能的更高级示例吗?请查看 存储库 。它包含一个样本报告: