Mert Özen Her Satırda Daha İleri
Backend .NET'te E-posta, PDF, Excel ve Dosya İşleri — #05

PDF Üretmek 1: QuestPDF ile Fatura ve Rapor, Font'lar ve Türkçe Karakterler

Mert Özen 15 Kas 2026 12 dk 9 görüntülenme
PDF Üretmek 1: QuestPDF ile Fatura ve Rapor, Font'lar ve Türkçe Karakterler

QuestPDF ile faturayı fluent layout'la kur, tablo başlığını her sayfada tekrarla, sayfa numarasını footer'a koy ve font'u uygulamayla dağıt: 2026.9'dan beri sistem font'ları varsayılan olarak kapalı ve eksik font ya da glyph exception fırlatıyor. Bookstore faturası paketteki Lato ile Türkçe karakterleri container'da da doğru basıyor; Community License satırı bir beyan, yıllık geliri 1 milyon USD altındaki şirketler için.

Sürüm notu: Bu yazıdaki kodlar .NET 10, QuestPDF 2026.9.2 ve PdfPig 0.1.16 ile derlenip denenmiştir. QuestPDF lisans bilgisi 15 Kasım 2026'da resmi lisans rehberinden kontrol edildi.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) sipariş faturasını PDF olarak üretecek. Fatura adresi "Bağdat Caddesi No: 12, Kadıköy, İstanbul". Üç soru:

Birincisi: QuestPDF'i projeye ekleyip ilk dokümanı hiçbir ayar yapmadan üretiyorsun. Ne olur?

İkincisi: adres satırına .FontFamily("Arial") verdin. Windows'taki geliştirme makinende Arial yüklü. PDF üretilir mi? Peki aynı kod bir Linux container'ında çalışınca?

Üçüncüsü: bir müşteri Murakami'nin bir romanını Japonca başlığıyla sipariş etti. Fatura üretilirken ne olur?

Cevaplar QuestPDF 2026.9.2 ile gerçek koşudan: ilkinde bir exception alıyorsun ve mesajı sana lisans tipini seçmeni söylüyor. İkincisinde geliştirme makinende de hata alıyorsun, çünkü 2026.9.0'dan beri sistem font'ları varsayılan olarak kullanılmıyor; açık ayarla makinende çalışan kod container'da yine patlıyor. Üçüncüsünde üretim, eksik glyph'leri tek tek sayan bir DocumentDrawingException ile duruyor. Üçü de sessizce bozuk bir PDF üretmekten iyidir.

QuestPDF, dokümanı C# kodunda fluent bir API ile tarif etmeni sağlayan bir kütüphane. HTML ya da şablon dosyası yok; sayfayı container'ları iç içe koyarak kuruyorsun ve layout motoru metni ölçüp satırları ve sayfaları kendisi bölüyor. Fatura renderer'ı birinci yazıdaki IDocumentRenderer<InvoiceModel>'ın PDF implementasyonu:

private static void Compose(IDocumentContainer container, InvoiceModel model)
{
    container.Page(page =>
    {
        page.Size(PageSizes.A4);
        page.Margin(36);
        page.DefaultTextStyle(text => text.FontSize(10).FontColor(Colors.Grey.Darken4));

        page.Header().Element(header => ComposeHeader(header, model));
        page.Content().PaddingVertical(16).Element(content => ComposeContent(content, model));
        page.Footer().AlignCenter().Text(text =>
        {
            text.Span("Page ");
            text.CurrentPageNumber();
            text.Span(" / ");
            text.TotalPages();
        });
    });
}

Header ve Footer her sayfada tekrar ediyor; Content gerektiği kadar sayfaya yayılıyor. Ölçüler point cinsinden (1 inch = 72 point), yani 36 point'lik kenar boşluğu yarım inch. CurrentPageNumber ve TotalPages değerleri layout bittikten sonra doluyor; toplam sayfa sayısını önceden hesaplaman gerekmiyor. Başlıkta logo ile fatura bilgisi yan yana duruyor:

private static void ComposeHeader(IContainer container, InvoiceModel model)
{
    container.Row(row =>
    {
        row.ConstantItem(150).Height(40).Image(Logo).FitArea();
        row.RelativeItem().AlignRight().Column(column =>
        {
            column.Item().Text($"Invoice {model.InvoiceNumber}").FontSize(16).Bold();
            column.Item().Text($"Order #{model.OrderId}");
            column.Item().Text($"Issue date: {model.IssueDate.ToString("d MMMM yyyy", Tr)}");
        });
    });
}

Row ve Column iki temel yapı taşı: ConstantItem sabit genişlik, RelativeItem kalan alanın payı. Logo assembly'ye gömülü bir PNG (kayıpsız sıkıştırılmış görsel formatı); sunucuda dosya yolu aramak zorunda kalmıyoruz.

Tablo, Tekrar Eden Başlık ve Sayfa Kırılması

Fatura satırları bir Table:

container.Table(table =>
{
    table.ColumnsDefinition(columns =>
    {
        columns.ConstantColumn(95);
        columns.RelativeColumn();
        columns.ConstantColumn(40);
        columns.ConstantColumn(70);
        columns.ConstantColumn(40);
        columns.ConstantColumn(75);
    });

    // Tablonun devam ettiği her sayfanın başında tekrarlanır
    table.Header(header =>
    {
        foreach (string title in new[] { "ISBN", "Title", "Qty", "Unit price", "VAT", "Net amount" })
        {
            header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingVertical(4).Text(title).Bold();
        }
    });

    foreach (InvoiceLine line in model.Lines)
    {
        table.Cell().Element(Cell).Text(line.Isbn);
        table.Cell().Element(Cell).Text(line.Title);
        table.Cell().Element(Cell).AlignRight().Text(line.Quantity.ToString(Tr));
        table.Cell().Element(Cell).AlignRight().Text(line.UnitPrice.ToString("C", Tr));
        table.Cell().Element(Cell).AlignRight().Text(line.VatRate.ToString("P0", Tr));
        table.Cell().Element(Cell).AlignRight().Text(line.NetAmount.ToString("C", Tr));
    }
});

Kurumsal siparişlerde fatura onlarca satır olabiliyor. Sayfa kırılmasını gözle kontrol etmek yerine 120 satırlık bir fatura üretip her sayfanın metnini PdfPig (Apache 2.0 lisanslı bir PDF okuma kütüphanesi) ile çıkardık:

$ dotnet run -- long 120
page 1: header row=True lines=32 footer='Page 1 / 4'
page 2: header row=True lines=36 footer='Page 2 / 4'
page 3: header row=True lines=36 footer='Page 3 / 4'
page 4: header row=True lines=16 footer='Page 4 / 4'

Dört sayfanın her birinde tablo başlığı var, satırlar 32 + 36 + 36 + 16 = 120 ve footer sayfa sayısını doğru yazıyor. İlk sayfada başlık bloğu ve adresler yer kapladığı için daha az satır var. Aynı kontrolü bir xUnit testine koyduk; şablona bir sütun eklendiğinde sayfa düzeni bozulursa test kırılıyor.

Para, Tarih ve KDV Özeti

Tutarlar decimal, formatlar tr-TR kültürüyle: ToString("C", Tr) "₺1.702,00", ToString("P0", Tr) "%10" üretiyor. KDV (katma değer vergisi) özeti satırları orana göre grupluyor:

foreach (IGrouping<decimal, InvoiceLine> rate in model.Lines.GroupBy(l => l.VatRate).OrderBy(g => g.Key))
{
    column.Item().Row(row =>
    {
        row.RelativeItem().Text($"VAT {rate.Key.ToString("P0", Tr)} on {rate.Sum(l => l.NetAmount).ToString("C", Tr)}");
        row.ConstantItem(80).AlignRight().Text(rate.Sum(l => l.VatAmount).ToString("C", Tr));
    });
}

Satır KDV'si InvoiceModel'de MidpointRounding.AwayFromZero ile ikiye yuvarlanıyor; özet, yuvarlanmış satır tutarlarının toplamı. Örnekteki yüzde 10 ve yüzde 20 oranları yalnız gösterim için; hangi ürüne hangi oranın uygulanacağına muhasebe karar verir ve oran koda değil katalog verisine aittir. Üretilen faturanın ilk sayfası:

05-invoice-page-tr.png

Görüntüyü QuestPDF'in GenerateImages method'uyla aynı dokümandan aldık. PDF'in metnini PdfPig ile okuduğumuzda Türkçe karakterler ve toplamlar yerinde:

$ dotnet run -- invoice
out/invoice.pdf 38 KB in 99 ms
pages=1
  ...
  Bookstore Ada Lovelace
  orders@example.com Bağdat Caddesi No: 12, Kadıköy
  34710 İstanbul, Türkiye
  ...
  VAT %10 on ₺1.520,00 ₺152,00
  VAT %20 on ₺25,00 ₺5,00
  Net total ₺1.545,00
  VAT total ₺157,00
  Grand total ₺1.702,00
  Page 1 / 1

Türkiye'de yasal fatura e-Fatura ya da e-Arşiv sistemiyle, GİB'e (Gelir İdaresi Başkanlığı) iletilen, UBL (Universal Business Language; ticari belgeler için standart XML şeması) tabanlı UBL-TR XML'i olarak kesiliyor. Burada ürettiğimiz PDF, müşteriye gönderilen görsel kopya; yasal entegrasyon ayrı bir iş.

Font'lar ve Türkçe Karakterler

Bir PDF'in her yerde aynı görünmesi için kullandığı font'u içine gömmesi gerekiyor. QuestPDF bunu kendisi yapıyor: kullanılan karakterlerin bulunduğu alt kümeyi (subset) PDF'e koyuyor, bu yüzden 38 KB'lık faturanın içinde font da var. Asıl soru, font'un nereden geldiği.

QuestPDF paketin içinde Lato font ailesini getiriyor ve varsayılan font o. Lato'nun Türkçe karakterleri (ı, İ, ş, ğ) ve ₺ işareti var; faturamız ek bir font olmadan doğru çıktı. 2026.9.0 sürümünde font davranışı önemli ölçüde değişti. UseSystemFonts artık varsayılan olarak kapalı, yani işletim sistemindeki font'lar kullanılmıyor. Eksik font ailesi (ThrowOnMissingFontFamilies) ve eksik glyph (ThrowOnMissingTextGlyphs) artık her zaman exception fırlatıyor. Eskiden ikinci kontrol yalnız debugger bağlıyken çalışıyordu. Tahmin sorusundaki Arial denemesi:

$ dotnet run -- font Arial
DocumentDrawingException: The text "Bağdat Caddesi No: 12, Kadıköy" uses font families that are not available: 'Arial'.

Since version 2026.9.0, system fonts are not used by default, and an unavailable font family stops document generation instead of being silently replaced with another font. More info: https://www.questpdf.com/api-reference/text/font-management.html

Font families used in this text block: 'Arial', 'Lato'

QuestPDF.Settings.UseSystemFonts = true ile Arial yüklü bir geliştirme makinesinde aynı komut çalışıyor. Aynı derlenmiş uygulamayı mcr.microsoft.com/dotnet/runtime:10.0 image'ında çalıştırdığımızda ise:

$ docker run --rm -v ./bin/Debug/net10.0:/app:ro -w /tmp mcr.microsoft.com/dotnet/runtime:10.0 \
    sh -c 'dotnet /app/Bookstore.PdfLab.dll invoice | head -9; echo ---; dotnet /app/Bookstore.PdfLab.dll font Arial --system-fonts | head -3; echo ---; ls /usr/share/fonts'
out/invoice.pdf 38 KB in 179 ms
pages=1
  Invoice BKS-2026-001042
  Order #1042
  Issue date: 14 Kasım 2026
  Seller Bill to
  Bookstore Ada Lovelace
  orders@example.com Bağdat Caddesi No: 12, Kadıköy
  34710 İstanbul, Türkiye
---
DocumentDrawingException: The text "Bağdat Caddesi No: 12, Kadıköy" uses font families that are not available: 'Arial'.

Since version 2026.9.0, system fonts are not used by default, and an unavailable font family stops document generation instead of being silently replaced with another font. More info: https://www.questpdf.com/api-reference/text/font-management.html
---
ls: cannot access '/usr/share/fonts': No such file or directory

Lato ile üretilen fatura container'da da aynen çıkıyor, çünkü font paketle geliyor. Sistem font'una dayanan kod ise container'da duruyor: resmi .NET runtime image'ında /usr/share/fonts klasörü bile yok. Yeni varsayılanların yaptığı tam olarak bu: "makinemde çalışıyor" sorununu deploy'dan önceye, geliştirme makinesine çekiyor. Eski sürümlerde aynı durum hata vermeden başka bir font'a düşüyordu ve bozuk karakterli PDF'ler production'da fark ediliyordu.

Japonca başlık ise Lato'nun kapsamı dışında:

$ dotnet run -- glyphs
DocumentDrawingException: The text "Haruki Murakami: 海辺のカフカ" contains 5 glyph(s) that are not available in the configured fonts or their fallbacks.

Missing glyphs: U+306E 'の', U+30AB 'カ', U+30D5 'フ', U+6D77 '海', U+8FBA '辺'

Çözüm, gereken font'u uygulamayla birlikte dağıtmak. Settings.FontDiscoveryPath varsayılan olarak uygulama klasörü ve QuestPDF oradaki .ttf ve .otf dosyalarını kendisi kaydediyor. Projeye bir Fonts klasörü ekleyip lisansı uygun bir font'u çıktıya kopyalamak ve metin stiline fallback zinciri vermek yeterli. Japonca için örneğin Noto Sans JP (Noto ailesinin Japonca karakterleri içeren sürümü) uygun; SIL (font lisanslarını hazırlayan kuruluş) Open Font License ile dağıtılıyor. Font dosyası yerindeyken fallback zinciri şöyle:

// Noto Sans JP font dosyası uygulamayla birlikte dağıtılmalı
table.Cell().Element(Cell).Text(line.Title).FontFamily("Lato", "Noto Sans JP");

Font'un lisansını da kütüphane lisansı gibi kontrol et: font'u PDF'e gömmek bir dağıtım biçimi ve her font lisansı buna izin vermiyor. Windows'taki ticari font'ları sunucudaki bir container'a kopyalamak çoğu zaman lisansa aykırı. Bu bölümde resmî belgelerden yararlanılmıştır.

Community License'ı Kodda Belirtmek

Tahmin sorusunun ilk cevabı, lisans satırı olmadan alınan exception:

Unhandled exception. System.Exception: Please configure the QuestPDF license by setting 'QuestPDF.Settings.License' at application startup.
...
> QuestPDF.Settings.License = LicenseType.Evaluation; // or Community / Professional / Enterprise
...
No license key or activation is required: the setting is a self-declaration, and we simply trust you.

Mesajın söylediği gibi bu bir lisans anahtarı değil, beyan. Bookstore.Api'de uygulama başlarken bir kez yazılıyor:

// Bookstore QuestPDF Community License'a uygun (yıllık brüt gelir 1 milyon USD altında); başlangıçta bir kez set edilir
QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community;

Bookstore'un küçük bir şirket olduğunu ve bu koşulu sağladığını varsayıyoruz. Kimlerin bu satırı yazabileceğini lisans rehberinin 6 Temmuz 2026 tarihli 3.0 sürümü belirliyor. Gruplar şunlar: bireyler, son tamamlanan mali yılda yıllık brüt geliri 1 milyon USD'nin altında kalan şirketler (aynı kontrol altındaki şirketlerin toplamıyla), OSI (open source lisanslarını onaylayan kuruluş) onaylı lisansla dağıtılan open source projeler, hayır kurumları ve kâr amacı gütmeyen akademik kurumlar. Kamu kurumları ve borsada işlem gören şirketler gelirleri ne olursa olsun Community License kullanamıyor. Öğrenme ve değerlendirme amaçlı bireysel kullanım gelirden bağımsız serbest. Uygun olmayan bir kurum LicenseType.Evaluation ile 60 güne kadar, yalnız production dışında deneyebiliyor; production'dan önce Professional ya da Enterprise lisans gerekiyor. Exception mesajındaki özete göre iki ücretli tipin ikisi de sınırsız developer'ı kapsıyor, Enterprise bağlı şirketleri de içine alıyor.

Bu kararı kim veriyor? Kodu yazan developer değil; şirketin gelirini ve yapısını bilen biri. Birinci yazıdaki lisans kontrolü burada işe yaradı: QuestPDF'i eklediğimiz anda eng/check-licenses.sh 1 ile çıktı. Kararı sürüme bağlı bir override olarak kaydettik ve kontrol tekrar yeşile döndü:

[
  {
    "Id": "QuestPDF",
    "Version": "2026.9.2",
    "License": "LicenseRef-QuestPDF-Community"
  }
]

Şirket büyüdüğünde geçiş için resmi fiyat sayfası, gelirin 1 milyon USD'yi aştığı mali yılın sonundan itibaren 90 günlük bir süre tanıyor. Bu yüzden kararı her sürüm yükseltmesinde ve her mali yıl sonunda yeniden kontrol etmek gerekiyor.

Faturayı Bir Kez Üretip Saklamak

QuestPDF hızlı: 100 faturayı tek thread'de 193 milisaniyede ürettik, yani fatura başına 2 milisaniye. Request içinde üretmek bu yüzden mümkün. Yine de faturayı her indirmede yeniden üretmek yanlış: kesilmiş bir fatura değişmemeli. Şablona yeni bir sütun eklediğinde ya da adres sonradan değiştiğinde, geçen ayın faturası farklı bir PDF olarak inmemeli. Faturayı ilk istekte üretip IFileStore'a yazan küçük bir service:

// Kesilmiş fatura değişmez: bir kez render et, dosyayı sakla, her indirmede aynı byte'ları sun
public sealed class InvoiceArchive(IDocumentRenderer<InvoiceModel> renderer, IFileStore files)
{
    public static string PathFor(InvoiceModel invoice) =>
        $"invoices/{invoice.IssueDate:yyyy}/{invoice.InvoiceNumber}{FileFormat.Pdf.Extension}";

    public async Task<string> EnsureStoredAsync(InvoiceModel invoice, CancellationToken cancellationToken = default)
    {
        string path = PathFor(invoice);
        if (await files.ExistsAsync(path, cancellationToken))
        {
            return path;
        }

        using MemoryStream pdf = new();
        await renderer.RenderAsync(invoice, pdf, cancellationToken);
        pdf.Position = 0;
        await files.SaveAsync(path, pdf, renderer.Format.ContentType, cancellationToken);
        return path;
    }
}

IFileStore'a bunun için ExistsAsync ekledik. İki ardışık indirme:

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename=invoice-BKS-2026-001042.pdf; filename*=UTF-8''invoice-BKS-2026-001042.pdf
time=0.140990s size=39870
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename=invoice-BKS-2026-001042.pdf; filename*=UTF-8''invoice-BKS-2026-001042.pdf
time=0.001308s size=39870

İlki 141 milisaniye (uygulamanın ilk PDF'inde yapılan yüklemeler dahil), ikincisi 1 milisaniye ve iki dosya byte byte aynı. Birinci yazıda renderer'ı Results.Stream ile doğrudan yanıta bağlamıştık. QuestPDF de ClosedXML gibi senkron yazdığı için renderer önce MemoryStream'e üretiyor, sonra CopyToAsync ile kopyalıyor; yoksa bir önceki yazıdaki "Synchronous operations are disallowed" hatasını alırdık. Production'da faturanın üretimi sipariş onaylandığında çalışan bir job'a gidiyor ve dosya hazır olduğunda e-postaya ekleniyor ya da link olarak gönderiliyor. O zinciri serinin son yazısında kuruyoruz.

Geliştirme Döngüsü: Companion ve Testler

QuestPDF'in Companion adlı bir masaüstü uygulaması var. document.ShowInCompanion() çağrısı dokümanı uygulamaya gönderiyor ve IDE'nin hot reload'u ile kodu değiştirdikçe önizleme yenileniyor. Biz bu yazıda Companion'ı kullanmadık. Döngüyü PdfLab console uygulaması, GenerateImages ile alınan PNG ve PdfPig testleriyle kurduk. Görsel düzeni gözle kontrol etmek için Companion ya da PNG işe yarıyor, ama regresyonu yakalayan test:

[Fact]
public async Task Turkish_characters_and_totals_survive_in_the_pdf_text()
{
    using PdfDocument pdf = await RenderAsync(BookstoreSampleData.Invoice(1042));
    string text = string.Join(" ", pdf.GetPage(1).GetWords().Select(w => w.Text));

    Assert.Contains("Bağdat Caddesi No: 12, Kadıköy", text);
    Assert.Contains("34710 İstanbul, Türkiye", text);
    Assert.Contains("Grand total ₺1.702,00", text);
}

PDF'ten metin çıkarmak, glyph'lerin doğru Unicode karşılıklarıyla gömüldüğünü de doğruluyor. Kopyala-yapıştır ve ekran okuyucular bu eşlemeye bakıyor. Test sınıfı da aynı lisans satırını constructor'da çalıştırıyor.

Üretimde Dikkat

Lisans satırını Program.cs'in en başına koy. QuestPDF ilk dokümandan önce ayarı bekliyor; bir background job ilk faturayı Program.cs o satıra gelmeden üretirse exception alırsın.

Font'ları uygulamayla dağıt ve UseSystemFonts'u kapalı bırak. Docker image'ına font paketi kurmak da bir yol, ama hangi font'un hangi sürümünün geleceğini image'ın dağıtımı belirler; uygulama klasöründeki font dosyası her ortamda aynıdır.

QuestPDF native bir kütüphane (Skia tabanlı) kullanıyor ve paket Windows, Linux (glibc ve musl) ve macOS için hazır binary'lerle geliyor. Container'da framework-dependent yayınladığında runtimes klasörünün imaja kopyalandığından emin ol; yukarıdaki container denemesi bunu doğruluyor.

Büyük raporlarda bellek ve süre: QuestPDF layout'u bellekte hesaplıyor. 120 satırlık fatura 53 KB ve milisaniyeler sürüyor, ama yüzlerce sayfalık bir rapor job'a gitmeli. Kod dışarıdan gelen bir yazıyı ya da görseli dokümana koyuyorsa boyut sınırı koy.

Faturayı kişisel veri olarak koru. Adres ve e-posta içeriyor; dosya yolunu tahmin edilebilir bırakıyorsan (invoices/2026/BKS-2026-001042.pdf) indirme endpoint'inde sahiplik kontrolü şart. Sekizinci yazıda kısa ömürlü imzalı URL'lerle bu sorunu storage tarafında çözüyoruz.

Sık Düşülen Tuzaklar

Gelir eşiğini aşan bir şirkette Community License kullanmak. Satır bir beyan; doğruluğu şirketin sorumluluğu. Eşik grup şirketlerinin toplam geliri üzerinden hesaplanıyor ve kamu kurumları ile borsada işlem gören şirketler gelirden bağımsız olarak kapsam dışında.

Büyük tabloda sayfa kırılmasını test etmemek. Üç satırlık örnekte düzgün görünen fatura, 120 satırda başlıksız ikinci sayfayla ya da footer'ın üstüne binen bir toplamla çıkabilir.

Sistem font'una güvenmek. Geliştirme makinesinde Arial, container'da hiçbir şey; 2026.9'dan önceki sürümlerde bu sessizce başka bir font'a düşüyordu.

ThrowOnMissingTextGlyphs'i kapatmak. Hata kaybolur, ama müşteri faturasında kitap adının yerinde boş kutular görür.

Faturayı her indirmede yeniden üretmek. Şablon ya da veri değiştiğinde aynı numaralı faturanın iki farklı hâli dolaşıma girer.

Küçük Bir Deneme

★☆☆ BookstoreSampleData.LongInvoice ile 33, 34 ve 35 satırlık faturalar üret ve PdfLab'in long komutuyla sayfa sayılarına bak. KDV özeti ikinci sayfaya hangi satır sayısında geçiyor? Özetin tablodan ayrı bir sayfaya düşmesini istemiyorsan QuestPDF'in ShowEntire gibi hangi container'larına bakabilirsin?

★★☆ Uygulama klasörüne bir Fonts klasörü ekle, lisansı uygun bir Japonca font koy ve glyphs komutunu FontFamily("Lato", "<font-family>") ile tekrar çalıştır. PDF boyutu ne kadar değişiyor?

★★★ InvoiceArchive'i, sipariş onaylandığında üçüncü yazıdaki outbox'a "faturayı üret" işi yazan bir yapıya dönüştür. Dispatcher faturayı üretsin, IFileStore'a yazsın ve sipariş onay e-postasına PDF'i eklesin. Aynı sipariş için job iki kez çalışırsa hâlâ tek bir PDF dosyası mı kalıyor?

QuestPDF ile faturayı kodla çizdik. Bir sonraki yazıda tersini yapıyoruz: tasarımcının HTML ve CSS ile hazırladığı kurumsal teklif belgesini Playwright'ın headless Chromium'u ile PDF'e çeviriyor, PdfPig ile PDF okuyor, PDF'leri birleştiriyor ve iki yaklaşımı yan yana koyuyoruz.