Найпоширеніший підхід — конвертувати HTML у PDF через Puppeteer або WeasyPrint:
Проблеми HTML-to-PDF:
QuestPDF підхід:
dotnet add package QuestPDF
using QuestPDF.Infrastructure;
// Для некомерційних проєктів — Community ліцензія безкоштовна
QuestPDF.Settings.License = LicenseType.Community;
QuestPDF будується на концепції контейнерів (Containers), де кожен контейнер визначає, як його дочірні елементи розташовані:
Document
└── Page
├── Header (фіксований вгорі кожної сторінки)
├── Content (основний контент)
│ ├── Column (вертикальне розташування)
│ │ ├── Row (горизонтальне розташування)
│ │ │ ├── Text
│ │ │ └── Image
│ │ └── Table
└── Footer (фіксований внизу кожної сторінки)
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");
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);
});
// З файлу
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();
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());
}
});
Для складних документів рекомендується розкладати UI на компоненти — аналог React Components:
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);
});
});
}
}
public class InvoiceItemsComponent : IComponent
{
private readonly List<InvoiceItem> _items;
public InvoiceItemsComponent(List<InvoiceItem> items) => _items = items;
public void Compose(IContainer container)
{
container.Table(table =>
{
/* ... таблиця як вище ... */
});
}
}
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();
});
});
}
}
[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;
}
QuestPDF надає утиліту для перегляду документу прямо в браузері під час розробки:
// Запустіть цей код консольного додатку:
var invoice = Invoice.GenerateSample(); // Тестові дані
var document = new InvoiceDocument(invoice);
// Відкриває браузер з live preview (оновлюється при кожному перебілді)
document.ShowInPreviewer();
File(pdfBytes, "application/pdf", "users.pdf").IComponent для Header (логотип + назва компанії) та Footer (сторінка X з Y + дата генерації). Використайте їх у двох різних документах.OrderReportDocument з: 1) Cover Page (заголовок, дата, кількість замовлень), 2) таблиця замовлень з підсумками по CustomerName, 3) Footer з нумерацією сторінок. Ендпоінт GET /api/reports/orders/pdf?from=2024-01-01&to=2024-01-31.Нативний .NET
Layout-based
Компоненти
IComponent дозволяє будувати бібліотеку повторно використовуваних блоків документу.Streaming
Посилання:
Відправка Email з FluentEmail в ASP.NET Core
FluentEmail: fluent-інтерфейс для відправки листів, Razor-шаблони, інтеграція з SMTP та SendGrid, очереди, retry та тестування без реального SMTP.
Генерація тестових даних з Bogus в ASP.NET Core
Bogus (Faker.NET): генерація реалістичних тестових даних — імена, адреси, телефони, дати. Seed-data для БД, локалізація, складні графи об