Популярні бібліотеки

Генерація PDF з QuestPDF в ASP.NET Core

QuestPDF: layout-based підхід до генерації PDF, компоненти Text, Image, Table, Column/Row, стилізація, динамічні документи та потоковий вивід у ASP.NET Core.

Генерація PDF з QuestPDF в ASP.NET Core

Генерація PDF завжди була болючою точкою у .NET: застаріли бібліотеки, повільний HTML→PDF конвертер через headless Chromium, нестабільна верстка на різних платформах. QuestPDF пропонує programmatic layout-based підхід: ви описуєте документ через fluent C# API так само, як Flutter описує UI або як CSS Grid описує сітку.

1. Чому не HTML-to-PDF?

Найпоширеніший підхід — конвертувати HTML у PDF через Puppeteer або WeasyPrint:

Проблеми HTML-to-PDF:

  • Запускає окремий процес браузера (Chromium) — сотні МБ пам'яті.
  • Залежить від системних шрифтів на сервері.
  • Поведінка CSS у PDF непередбачувана (page-break, floats).
  • Складно контролювати точне позиціонування елементів.
  • Повільно: кожен PDF — це повний цикл браузера.

QuestPDF підхід:

  • Нативний .NET: нуль зовнішніх процесів.
  • Повний контроль над макетом через C# код.
  • Предбачувана поведінка: макет той самий на Windows, Linux, Docker.
  • Швидко: генерація сотень сторінок за секунди.

2. Встановлення та ліцензія

Встановлення
dotnet add package QuestPDF
Program.cs — ліцензія
using QuestPDF.Infrastructure;

// Для некомерційних проєктів — Community ліцензія безкоштовна
QuestPDF.Settings.License = LicenseType.Community;
QuestPDF Community Edition безкоштовна для некомерційних проєктів та проєктів з доходом до $1M/рік. Для комерційних — Professional або Enterprise ліцензія.

3. Концептуальна модель: Document → Section → Column/Row

QuestPDF будується на концепції контейнерів (Containers), де кожен контейнер визначає, як його дочірні елементи розташовані:

Document
  └── Page
        ├── Header (фіксований вгорі кожної сторінки)
        ├── Content (основний контент)
        │   ├── Column (вертикальне розташування)
        │   │   ├── Row (горизонтальне розташування)
        │   │   │   ├── Text
        │   │   │   └── Image
        │   │   └── Table
        └── Footer (фіксований внизу кожної сторінки)

4. Перший документ

Мінімальний PDF

Перший документ — Hello World
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;

Document.Create(container =>
{
    container.Page(page =>
    {
        page.Size(PageSizes.A4);
        page.Margin(2, Unit.Centimetre);
        page.DefaultTextStyle(x => x.FontSize(12).FontFamily("Arial"));

        page.Header()
            .Text("Мій перший PDF документ")
            .SemiBold()
            .FontSize(20)
            .FontColor(Colors.Blue.Medium);

        page.Content()
            .PaddingVertical(1, Unit.Centimetre)
            .Column(col =>
            {
                col.Item().Text("Привіт, QuestPDF!");
                col.Item().Text("Цей документ згенеровано програмно.");
            });

        page.Footer()
            .AlignCenter()
            .Text(txt =>
            {
                txt.Span("Сторінка ");
                txt.CurrentPageNumber();
                txt.Span(" з ");
                txt.TotalPages();
            });
    });
})
.GeneratePdf("output.pdf");

5. Компоненти: Text, Image, Table

Text: Форматований текст

Text компоненти
column.Item().Text(text =>
{
    // Різні частини тексту в одному рядку
    text.Span("Статус: ").FontSize(14).Bold();

    text.Span("Активний")
        .FontColor(Colors.Green.Darken2)
        .Bold();

    text.Span(" (оновлено ")
        .Light()
        .FontColor(Colors.Grey.Medium);

    text.Span("2024-01-15")
        .Italic();

    text.Span(")")
        .Light()
        .FontColor(Colors.Grey.Medium);
});

Image: Зображення

Вставка зображень
// З файлу
column.Item()
    .Height(200)
    .Image("/path/to/logo.png")
    .FitHeight();

// З байтів (наприклад, з БД або генероване)
byte[] imageBytes = /* ... */;
column.Item()
    .Width(300)
    .Image(imageBytes);

// Placeholder під час розробки
column.Item()
    .Width(200)
    .Height(150)
    .Placeholder();

Table: Таблиці

Таблиця — рахунок-фактура
column.Item().Table(table =>
{
    // Визначення колонок
    table.ColumnsDefinition(cols =>
    {
        cols.ConstantColumn(30);   // №    — фіксована ширина
        cols.RelativeColumn(4);    // Назва — відносна ширина
        cols.RelativeColumn(1);    // Кіл.
        cols.RelativeColumn(1.5f); // Ціна
        cols.RelativeColumn(1.5f); // Сума
    });

    // Заголовок таблиці (повторюється на кожній сторінці)
    table.Header(header =>
    {
        var headerStyle = TextStyle.Default
            .FontSize(10)
            .Bold()
            .FontColor(Colors.White);

        void AddHeaderCell(string text)
        {
            header.Cell()
                .Background(Colors.Blue.Medium)
                .Padding(5)
                .AlignCenter()
                .Text(text)
                .Style(headerStyle);
        }

        AddHeaderCell("№");
        AddHeaderCell("Найменування");
        AddHeaderCell("Кіл.");
        AddHeaderCell("Ціна");
        AddHeaderCell("Сума");
    });

    // Рядки даних
    var items = new[]
    {
        ("Кава Colombia 250g", 2, 299.99m),
        ("Чай Sencha 100g",    3, 189.50m),
        ("Шоколад 72% 100g",   5,  89.90m),
    };

    for (int i = 0; i < items.Length; i++)
    {
        var (name, qty, price) = items[i];
        var total = qty * price;
        var bgColor = i % 2 == 0 ? Colors.Grey.Lighten5 : Colors.White;

        void Cell(Action<IContainer> content)
        {
            table.Cell()
                .Background(bgColor)
                .BorderBottom(0.5f, Unit.Point)
                .BorderColor(Colors.Grey.Lighten2)
                .Padding(5)
                .Element(content);
        }

        Cell(c => c.AlignCenter().Text((i + 1).ToString()));
        Cell(c => c.Text(name));
        Cell(c => c.AlignCenter().Text(qty.ToString()));
        Cell(c => c.AlignRight().Text($"₴{price:N2}"));
        Cell(c => c.AlignRight().Text($"₴{total:N2}").Bold());
    }
});

6. Компоненти: багаторазове використання через IComponent

Для складних документів рекомендується розкладати UI на компоненти — аналог React Components:

Components/InvoiceHeaderComponent.cs
public class InvoiceHeaderComponent : IComponent
{
    private readonly Invoice _invoice;

    public InvoiceHeaderComponent(Invoice invoice) => _invoice = invoice;

    public void Compose(IContainer container)
    {
        container.Row(row =>
        {
            // Логотип зліва
            row.ConstantItem(150)
                .Image("wwwroot/logo.png");

            row.RelativeItem();  // Spacer

            // Реквізити справа
            row.ConstantItem(200)
                .Column(col =>
                {
                    col.Item()
                        .Text("РАХУНОК-ФАКТУРА")
                        .FontSize(18)
                        .Bold()
                        .FontColor(Colors.Blue.Darken2);

                    col.Item()
                        .Text($"№ {_invoice.Number}")
                        .FontSize(14);

                    col.Item()
                        .PaddingTop(5)
                        .Text($"від {_invoice.Date:dd MMMM yyyy}р.")
                        .FontColor(Colors.Grey.Medium);
                });
        });
    }
}
Components/InvoiceItemsComponent.cs
public class InvoiceItemsComponent : IComponent
{
    private readonly List<InvoiceItem> _items;

    public InvoiceItemsComponent(List<InvoiceItem> items) => _items = items;

    public void Compose(IContainer container)
    {
        container.Table(table =>
        {
            /* ... таблиця як вище ... */
        });
    }
}
Documents/InvoiceDocument.cs — збирання документу
public class InvoiceDocument : IDocument
{
    private readonly Invoice _invoice;

    public InvoiceDocument(Invoice invoice) => _invoice = invoice;

    public DocumentMetadata GetMetadata() => DocumentMetadata.Default with
    {
        Title    = $"Рахунок № {_invoice.Number}",
        Author   = "MyApp",
        Creator  = "QuestPDF"
    };

    public void Compose(IDocumentContainer container)
    {
        container.Page(page =>
        {
            page.Size(PageSizes.A4);
            page.Margin(1.5f, Unit.Centimetre);
            page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));

            page.Header()
                .Component(new InvoiceHeaderComponent(_invoice))
                .PaddingBottom(10);

            page.Content()
                .Column(col =>
                {
                    // Дані відправника та отримувача
                    col.Item().Row(row =>
                    {
                        row.RelativeItem()
                            .Component(new PartyComponent("Від:", _invoice.Seller));
                        row.ConstantItem(30); // відступ
                        row.RelativeItem()
                            .Component(new PartyComponent("Кому:", _invoice.Buyer));
                    });

                    col.Item().PaddingTop(20)
                        .Component(new InvoiceItemsComponent(_invoice.Items));

                    col.Item().PaddingTop(10)
                        .Component(new InvoiceTotalsComponent(_invoice));
                });

            page.Footer()
                .AlignCenter()
                .Text(txt =>
                {
                    txt.Span("Сторінка ");
                    txt.CurrentPageNumber();
                    txt.Span(" з ");
                    txt.TotalPages();
                });
        });
    }
}

7. Генерація та відповідь у ASP.NET Core

Controllers/InvoicesController.cs — streaming PDF
[HttpGet("{id}/pdf")]
public async Task<IActionResult> DownloadPdf(int id)
{
    var invoice = await _db.Invoices
        .Include(i => i.Items)
        .Include(i => i.Buyer)
        .Include(i => i.Seller)
        .FirstOrDefaultAsync(i => i.Id == id);

    if (invoice is null) return NotFound();

    // Генеруємо PDF у пам'яті
    var document = new InvoiceDocument(invoice);
    var pdfBytes = document.GeneratePdf();

    // Повертаємо як файл для завантаження
    return File(
        fileContents:      pdfBytes,
        contentType:       "application/pdf",
        fileDownloadName:  $"invoice-{invoice.Number}.pdf");
}

// Або для Minimal API:
app.MapGet("/api/invoices/{id}/pdf", async (int id, AppDbContext db) =>
{
    var invoice = await db.Invoices.FindAsync(id);
    if (invoice is null) return Results.NotFound();

    var pdfBytes = new InvoiceDocument(invoice).GeneratePdf();

    return Results.File(
        fileContents: pdfBytes,
        contentType:  "application/pdf",
        fileDownloadName: $"invoice-{invoice.Number}.pdf");
});

Стрімінг (для великих документів)

Стрімінг без буферизації
[HttpGet("{id}/pdf/stream")]
public async Task<IActionResult> StreamPdf(int id)
{
    var invoice = await /* ... */;

    Response.ContentType = "application/pdf";
    Response.Headers.ContentDisposition =
        $"attachment; filename=\"invoice-{invoice.Number}.pdf\"";

    // Генеруємо напряму у Response.Body — без буферизації в пам'яті
    new InvoiceDocument(invoice).GeneratePdf(Response.Body);

    return Empty;
}

8. Налагодження: ShowCaseDocument

QuestPDF надає утиліту для перегляду документу прямо в браузері під час розробки:

Розробка — Hot Reload Preview
// Запустіть цей код консольного додатку:
var invoice = Invoice.GenerateSample(); // Тестові дані
var document = new InvoiceDocument(invoice);

// Відкриває браузер з live preview (оновлюється при кожному перебілді)
document.ShowInPreviewer();

Практичні завдання


Резюме

Нативний .NET

Нуль зовнішніх процесів. Немає headless Chrome. Працює у Docker без додаткових залежностей.

Layout-based

Row, Column, Table — ті самі концепції що у Flutter або CSS Grid. Передбачуваний результат.

Компоненти

IComponent дозволяє будувати бібліотеку повторно використовуваних блоків документу.

Streaming

Генерація напряму у Response.Body — мінімальне споживання пам'яті для великих документів.

Посилання: