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

PDF Üretmek 2: HTML'den PDF (Playwright), PDF Okumak, Birleştirmek ve Hangisi Ne Zaman

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
PDF Üretmek 2: HTML'den PDF (Playwright), PDF Okumak, Birleştirmek ve Hangisi Ne Zaman

Tasarımcının HTML ve CSS ile hazırladığı belgeyi Playwright'ın headless Chromium'u ile PDF'e çevir: browser'ı paylaş, her belgeye ayrı context aç, JavaScript'i kapat ve yalnız kendi font'larına izin ver. Bookstore'un kurumsal teklifi belge başına 38 milisaniyede çıkıyor; birleştirme ve okuma PdfPig ile, çünkü PDFsharp 6.5.0 ile AGPL'e geçti.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Playwright 1.64.1 (Chromium 156), PdfPig 0.1.16 ve QuestPDF 2026.9.2 ile derlenip denenmiştir. PDFsharp lisans bilgisi 15 Kasım 2026'da NuGet'ten ve resmi lisans SSS (sık sorulan sorular) sayfasından kontrol edildi.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) kurumsal müşterilere teklif gönderiyor. Teklif belgesini tasarımcı HTML ve CSS olarak hazırladı; bizim işimiz onu Hilltop Primary School'un kütüphanesine gidecek bir PDF'e çevirmek. Üç soru:

Birincisi: tasarımcının CSS dosyasını Razor template'ine <style>@Css</style> ile koyduk. PDF nasıl görünür?

İkincisi: her PDF için yeni bir headless Chromium başlatmak ile tek bir browser'ı paylaşmak arasında belge başına ne kadar fark var?

Üçüncüsü: iki PDF'i birleştirmek için yıllardır kullanılan PDFsharp'ın NuGet'teki son kararlı sürümünü ekledik. Birinci yazıdaki lisans kontrolü ne der?

Cevaplar gerçek koşudan. İlkinde PDF hata vermeden üretiliyor ama tasarımın hiçbir kuralı uygulanmıyor ve metin Times ile basılıyor. İkincisinde fark yaklaşık altı kat: belge başına 250 ve 38 milisaniye. Üçüncüsünde kontrol kırmızı yanıyor: PDFsharp 6.5.0 ile lisans MIT'den (kodu ticari projede de serbestçe kullanmaya izin veren lisans) AGPL'e (Affero General Public License; ağ üzerinden sunulan yazılımda da kaynak kodu paylaşma yükümlülüğü getiren copyleft lisans) geçti.

Tasarımcının HTML'i: Razor Component ve Print CSS

Beşinci yazıda faturayı QuestPDF ile kodla çizdik. Teklif belgesinde durum farklı: düzeni tasarımcı belirliyor, sık değişiyor ve tasarımcı C# bilmiyor. Bu yüzden markup bir Razor component'inde, stil ayrı bir CSS dosyasında duruyor. Üçüncü yazıdaki e-posta template'leri gibi component'i HtmlRenderer ile string'e render ediyoruz:

<table>
    <thead>
        <tr><th>ISBN</th><th>Title</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">Amount</th></tr>
    </thead>
    <tbody>
        @foreach (QuoteLine line in Model.Lines)
        {
            <tr>
                <td>@line.Isbn</td>
                <td>@line.Title</td>
                <td class="num">@line.Quantity.ToString(Tr)</td>
                <td class="num">@line.UnitPrice.ToString("C", Tr)</td>
                <td class="num">@line.Amount.ToString("C", Tr)</td>
            </tr>
        }
    </tbody>
</table>

Sayfa düzenini print CSS belirliyor. Tasarımcının dosyasındaki önemli kurallar:

/* Sayfa boyutu ve kenar boşlukları CSS'ten gelir (PreferCSSPageSize) */
@page { size: A4; margin: 22mm 16mm 20mm 16mm; }
/* Tablo başlığı tablonun devam ettiği her sayfada tekrarlanır */
thead { display: table-header-group; }
/* Bir satır ya da toplam bloğu iki sayfaya bölünmez */
tr { break-inside: avoid; }
.totals { margin-left: auto; width: 45%; margin-top: 14px; break-inside: avoid; }

İlk denemede CSS'i <style>@Css</style> ile koyduk. Razor her değeri HTML encode ediyor, <style> element'inin içi de buna dahil. Çıkan HTML'de font-family: &quot;Lato&quot;; vardı. Tarayıcı <style> içindeki entity'leri çözmediği için tırnak içeren her kural geçersiz sayıldı ve Chromium hiçbir uyarı vermeden varsayılan font'una döndü. PDF'teki font'ları PdfPig ile listeledik:

fonts: AAAAAA+Times-Bold, BAAAAA+Times-Bold, CAAAAA+Times-Roman, DAAAAA+Times-Bold, EAAAAA+Helvetica, FAAAAA+Times-Roman

CSS bizim gömülü dosyamız, kullanıcıdan gelmiyor. Bu yüzden onu @((MarkupString)Css) ile encode etmeden yazmak doğru. Modeldeki her değer ise encode edilmeye devam ediyor. Bu ayrım kullanıcı içeriğini anlattığımız bölümde yeniden önemli olacak.

Playwright for .NET, Chromium'u bir driver süreci üzerinden yönetiyor. PDF üretimi tek bir çağrı:

IPage page = await context.NewPageAsync();
page.SetDefaultTimeout((float)options.Value.RenderTimeout.TotalMilliseconds);
await page.SetContentAsync(html);
return await page.PdfAsync(new()
{
    Format = "A4",
    PrintBackground = true,
    PreferCSSPageSize = true,
    DisplayHeaderFooter = true,
    HeaderTemplate = "<span></span>",
    FooterTemplate = $"""
        <div style="font-family:sans-serif;font-size:8pt;color:#64748b;width:100%;padding:0 16mm;display:flex;justify-content:space-between">
            <span>{System.Net.WebUtility.HtmlEncode(footerTitle)}</span>
            <span>Page <span class="pageNumber"></span> / <span class="totalPages"></span></span>
        </div>
        """,
});

PrintBackground olmadan arka plan renkleri basılmıyor. Footer template'inde pageNumber ve totalPages class'ları Chromium tarafından dolduruluyor. Template ayrı render edildiği için kendi font boyutunu belirtmen gerekiyor, yoksa yazı okunmayacak kadar küçük çıkıyor. 60 satırlık bir teklif:

$ dotnet run -- quote 60
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
out/quote.pdf 63 KB in 309 ms
page 1: header row=True rows=25 total=False footer='Page 1 / 3'
page 2: header row=True rows=31 total=False footer='Page 2 / 3'
page 3: header row=True rows=4 total=True footer='Page 3 / 3'

Tablo başlığı her sayfada var, 25 + 31 + 4 = 60 satır ve toplam bloğu son sayfada bölünmeden duruyor.

Playwright'ın her sürümü belirli bir browser build'iyle çalışıyor. NuGet paketi çıktı klasörüne .playwright/package/browsers.json dosyasını koyuyor ve 1.64.1 için orada şu yazıyor:

{
  "name": "chromium-headless-shell",
  "revision": "1248",
  "installByDefault": true,
  "browserVersion": "156.0.8078.4",
  "title": "Chrome Headless Shell"
}

Makinede yalnız daha yeni bir Playwright'ın indirdiği browser (revision 1254) varken aynı kod başlamıyor:

Unhandled exception. Microsoft.Playwright.PlaywrightException: Executable doesn't exist at <browsers>/chromium_headless_shell-1248/.../chrome-headless-shell
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated.       ║
║ Please run the following command to download new browsers: ║
║                                                            ║
║     pwsh bin/Debug/netX/playwright.ps1 install             ║
║                                                            ║
║ <3 Playwright Team                                         ║
╚════════════════════════════════════════════════════════════╝

Çözüm paket sürümüyle gelen script'i çalıştırmak: pwsh bin/Debug/net10.0/playwright.ps1 install chromium. Paketi yükselttiğinde browser'ı da yükseltmen gerekiyor; bunu CI'da ve Dockerfile'da aynı script'le yapmak, sürümleri her zaman eşleşik tutuyor. Bilgisayarda kurulu Google Chrome'u (Channel = "chrome") sunucu tarafında kullanma: sürümü senin kontrolünde değil ve güncellendiğinde PDF'ler değişebilir.

Container tarafında bedel büyük. Microsoft'un mcr.microsoft.com/playwright/dotnet:v1.64.1-noble image'ı amd64 için sıkıştırılmış hâlde 1.397 MB, mcr.microsoft.com/dotnet/aspnet:10.0 ise 98 MB (ikisinin de manifest'indeki layer boyutlarını topladık). Playwright image'ı SDK'yı ve üç browser'ı birden taşıyor. Kendi image'ında yalnız Chromium'u ve onun Linux bağımlılıklarını (playwright.ps1 install --with-deps chromium) kurarak bunu küçültebilirsin. Yine de API image'ından ayrı bir "document worker" image'ı çoğu zaman daha doğru bir karar.

Font'lar: Chromium Sessizce Başka Font'a Düşer

QuestPDF eksik font'ta exception fırlatıyordu. Chromium bunu yapmıyor: CSS'teki font bulunamazsa sistemdeki bir font'u seçiyor ve devam ediyor. Bu da aynı HTML'in geliştirme makinesinde, CI'da ve container'da farklı font'larla basılması demek. Font'u uygulamayla birlikte dağıtmak için QuestPDF paketindeki Lato'nun iki dosyasını assembly'ye gömdük. Lato, SIL'in (font lisanslarını hazırlayan kuruluş) Open Font License'ı ile dağıtılıyor ve bu lisans font'un uygulamayla birlikte dağıtılmasına izin veriyor. CSS'te font'u var olmayan bir adresten istiyoruz:

@font-face { font-family: "Lato"; font-weight: 400; src: url("https://assets.bookstore.invalid/fonts/Lato-Regular.ttf"); }
@font-face { font-family: "Lato"; font-weight: 700; src: url("https://assets.bookstore.invalid/fonts/Lato-Bold.ttf"); }
body { font-family: "Lato", sans-serif; font-size: 10.5pt; color: #1f2937; margin: 0; }

Bu istekleri renderer yakalıyor ve bellekteki dosyayla cevaplıyor; .invalid alan adı tasarım gereği hiçbir zaman çözülmüyor:

await context.RouteAsync("**/*", async route =>
{
    // İzin listesi: kendi font'larımız bellekten verilir, geri kalan her istek iptal edilir
    if (route.Request.Url.StartsWith(AssetHost + "fonts/", StringComparison.Ordinal)
        && Fonts.TryGetValue(route.Request.Url[(AssetHost.Length + 6)..], out byte[]? font))
    {
        await route.FulfillAsync(new() { BodyBytes = font, ContentType = "font/ttf" });
        return;
    }

    logger.LogWarning("Blocked request from document: {Method} {Url}", route.Request.Method, route.Request.Url);
    await route.AbortAsync();
});

Sonuç:

fonts: AAAAAA+Lato-Bold, BAAAAA+Lato-Regular, CAAAAA+Times-Roman

Gövde artık Lato. Geriye kalan Times-Roman footer'dan geliyor. Header ve footer template'leri sayfadan ayrı render ediliyor ve sayfanın yüklediği web font'unu kullanamıyor; sans-serif yazmamıza rağmen bizim ortamımızda Times seçildi. Footer'da marka font'u şartsa onu sayfanın içine, position: fixed ile her sayfada tekrar eden bir element olarak koymak gerekiyor.

Browser'ın Yaşam Döngüsü

Chromium başlatmak pahalı: bir süreç açılıyor, profil hazırlanıyor, driver ile bağlantı kuruluyor. Aynı küçük HTML'den 20'şer PDF ürettik:

$ dotnet run -- lifetime 20
new browser per document: 20 PDFs in 5000 ms (250 ms each)
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
shared browser, new context: 20 PDFs in 764 ms (38 ms each)
shared browser, 20 in parallel (max 4 pages): 307 ms

Renderer bu yüzden singleton. Browser ilk belgede başlıyor; her belge kendi BrowserContext'ini açıp kapatıyor. Context, çerezleri ve storage'ı ayrı tutulan hafif bir profil gibi; bir teklifin durumu diğerine sızmıyor. Aynı anda açık sayfa sayısını bir SemaphoreSlim sınırlıyor, çünkü her sayfa browser'da ayrı bir render belleği tutuyor:

private async Task<IBrowser> GetBrowserAsync()
{
    if (_browser is { IsConnected: true })
    {
        return _browser;
    }

    await _startLock.WaitAsync();
    try
    {
        if (_browser is { IsConnected: true })
        {
            return _browser;
        }

        // İlk çağrı ya da browser süreci öldü: (yeniden) başlat
        _playwright ??= await Playwright.CreateAsync();
        _browser = await _playwright.Chromium.LaunchAsync(new() { Headless = true });
        logger.LogInformation("Chromium {Version} started", _browser.Version);
        return _browser;
    }
    finally
    {
        _startLock.Release();
    }
}

IsConnected kontrolü, browser süreci çökerse bir sonraki belgede yeniden başlatmayı sağlıyor. Renderer IAsyncDisposable ve uygulama kapanırken browser'ı kapatıyor; DI (dependency injection; bağımlılıkların dışarıdan verilmesi) container'ı singleton'ı dispose ettiği için ek bir şey yazmana gerek yok. API'de ilk teklif indirmesi browser başlangıcıyla birlikte 1,4 saniye, ikincisi 58 milisaniye sürdü.

Kullanıcı İçeriği, SSRF ve Yerel Dosyalar

HTML'i PDF'e çeviren bir browser, sunucunun içinde çalışan tam bir web istemcisi. Şablona giren içerik ona istek attırabiliyorsa SSRF (Server-Side Request Forgery; sunucuyu iç ağdaki adreslere istek atmaya kandırmak) kapısı açılıyor. Razor varsayılan olarak encode ettiği için tehlike, satış temsilcisi Hakan'ın biçimli notlarını göstermek için birinin MarkupString kullandığı gün başlıyor. Denemede iç ağda çalışan bir servisi 127.0.0.1:52566'daki küçük bir HttpListener ile taklit ettik ve nota şunları koyduk:

<iframe src="http://127.0.0.1:52566/admin/export" width="500" height="40"></iframe>
<iframe src="secret.txt" width="500" height="40"></iframe>
<script>document.body.append('script ran')</script>

Önce renderer'ımızla, sonra sık görülen kısa yolla, yani HTML'i dosyaya yazıp file:// adresiyle açarak:

$ dotnet run -- untrusted
info: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Chromium 156.0.8078.4 started
warn: Bookstore.Notifications.Documents.Html.ChromiumPdfRenderer[0] Blocked request from document: GET http://127.0.0.1:52566/admin/export
renderer: Quote Q-2026-0117 end of note Quote Page 1 / 1
internal service got: GET http://127.0.0.1:52566/admin/export
file:// page: Quote Q-2026-0117 contents of secret.txt script ran end of note

Renderer iç servise giden isteği iptal edip log'a yazdı. JavaScript kapalı olduğu için script çalışmadı. Sayfa about:blank üzerinde olduğu için Chromium göreli secret.txt yolunu da açmadı. Kısa yolda ise üçü de gerçekleşti: iç servis isteği aldı, yandaki dosyanın içeriği PDF'e girdi ve script çalıştı. Bulutta bu iç servis, sunucunun kimlik bilgilerini veren metadata adresi olabilir. Kurallar: HTML'i SetContentAsync ile ver, JavaScript'i kapat, route ile yalnız izin verdiğin adresleri cevapla ve kullanıcı HTML'ini kabul etmen gerekiyorsa önce sanitize et. Upload ve dosya tarafındaki güvenlik ASP.NET Core Güvenliği serisinin konusu.

PDF Okumak ve Birleştirmek: PdfPig ve PDFsharp'ın Lisansı

Teklif paketi iki parçadan oluşuyor: bizim ürettiğimiz teklif ve hukuk ekibinin hazır gönderdiği "Terms and Conditions" PDF'i. Önceki yazılarda okumak için kullandığımız PdfPig birleştirmeyi de yapabiliyor:

// PdfPig (Apache 2.0) ile mevcut PDF'leri okumak ve birleştirmek
public static class PdfFiles
{
    public static byte[] Merge(params byte[][] documents) => PdfMerger.Merge(documents);

    public static IReadOnlyList<string> PageTexts(byte[] pdf)
    {
        using PdfDocument document = PdfDocument.Open(pdf);
        return [.. document.GetPages().Select(page => string.Join(" ", page.GetWords().Select(w => w.Text)))];
    }
}
$ dotnet run -- merge
quote=1 page(s) + terms=1 page(s) -> 2 pages, 55 KB in 167 ms
page 1: Quote Q-2026-0117 Issued 15 Kasım 2026 Bookstore Valid until 15 Aralık
page 2: Terms and Conditions for Institutional Orders 1. Orders of 20 or more

Metin çıkarırken bir sürprizle karşılaştık. Teklifin notundaki "fixed" kelimesi PDF'ten "fixed" olarak, yani "fi" bitişik harfi (ligature) tek bir karakter olarak geldi. Chromium ligature'ları tek glyph olarak yazıyor. Assert.Contains("Prices are fixed", text) bu yüzden kırılıyordu; testte metni önce Normalize(NormalizationForm.FormKC) ile Unicode'un uyumluluk biçimine çeviriyoruz.

Neden PDFsharp değil? Planımız birleştirme için MIT lisanslı PDFsharp'tı. NuGet geçmişine göre 6.2.4 (6 Ocak 2026) MIT ile yayımlanmış, 14 Ekim 2026'da yayımlanan 6.5.0 ise AGPL-3.0-only. Resmi lisans SSS sayfası artık PDFsharp ve MigraDoc'un AGPL ve ticari lisansla çift lisanslı olduğunu söylüyor. Sürüm geçmişine göre 6.5.0'dan itibaren kodun lisans tipini seçmesi gerekiyor (PdfSharpLicense.SetAgplLicense() ya da ticari anahtar). 6.5.0'ı bir deneme projesine ekleyip birinci yazıdaki kontrolü çalıştırdık (sütunları kısalttık):

| Package  | Version | License Expression | Error                                                           |
| PdfPig   | 0.1.16  | Apache-2.0         |                                                                 |
| PDFsharp | 6.5.0   | AGPL-3.0-only      | License "AGPL-3.0-only" not found in list of supported licenses |

AGPL, kütüphaneyi kullanan ve ağ üzerinden hizmet veren bir uygulamanın kaynak kodunu kullanıcılarına açmasını isteyebiliyor; kapalı kaynak bir web API için bu ciddi bir karar ve geliştiricinin tek başına vereceği bir karar değil. 6.2.4'e sabitlemek mümkün, ama o zaman sonraki sürümlerin düzeltmelerinden vazgeçmiş olursun. Bookstore'da birleştirme işini zaten bağımlılık olarak bulunan PdfPig ile yaptık ve yeni bir lisans kararı gerekmedi.

QuestPDF mi HTML mi?

KonuQuestPDF (kod)HTML + Chromium
Düzeni kim değiştirirDeveloperTasarımcı (HTML ve CSS)
Belge başına süre~2 ms (fatura)~38 ms (paylaşılan browser)
Eksik fontExceptionSessizce başka font
DeployNuGet paketi ve native binaryBrowser ve Linux bağımlılıkları, büyük image
Güvenlik yüzeyiYalnız kendi kodunTam bir web istemcisi (SSRF, file://)
LisansCommunity License, gelir eşiğiPlaywright MIT, ek koşul yok
Uygun olduğu yerFatura, rapor, yüksek hacimTasarımı sık değişen, az sayıda belge

Bookstore'da ikisi birlikte kalıyor: günde binlerce kesilen fatura QuestPDF'te, ayda birkaç düzine hazırlanan kurumsal teklif HTML'de.

Üretimde Dikkat

PDF üretimini API sürecinden ayır. Chromium süreci belleği ve CPU'yu API ile paylaşır; bir teklif yükü API'nin yanıt sürelerini bozabilir. Teklifi bir job'a ver ve sonucu IFileStore'a yaz.

Zaman aşımı koy. SetDefaultTimeout sayfanın asılı kalmasını engelliyor. Renderer'ı çağıran job'ın da bir üst süresi olsun.

Browser'ın belleğini izle. Uzun çalışan bir Chromium süreci zamanla büyüyebiliyor. Belirli sayıda belgeden sonra browser'ı yeniden başlatmak basit ve etkili bir önlem.

Browser'ı paket sürümüyle birlikte yükselt. Playwright paketi ile browser build'i aynı script'le kurulmalı; sürüm eşleşmesini CI'da çalışan bir PDF testi yakalasın.

Belgeyi test et. Testimiz 60 satırlık teklifin üç sayfa olduğunu, her sayfada tablo başlığının bulunduğunu ve toplamın son sayfada olduğunu kontrol ediyor. Browser kurulu olmayan bir makinede kendini atlıyor.

Sık Düşülen Tuzaklar

Her istekte yeni browser başlatmak. Belge başına 250 milisaniye ve aynı anda gelen isteklerde onlarca Chromium süreci demek.

Kullanıcı içeriğini sanitize etmeden HTML template'ine koymak, hele HTML'i file:// ile açmak. Sonuç SSRF ve yerel dosya okuma.

CSS'i Razor ile encode ederek <style> içine koymak. Hata yok, tasarım yok.

Font'u sisteme bırakmak. Aynı belge her ortamda başka font'la basılır ve kimse hata görmez.

Bağımlılığın lisansını yalnız ilk eklerken kontrol etmek. PDFsharp örneğinde olduğu gibi lisans bir minor sürümde değişebiliyor; kontrol her restore'da çalışmalı.

Küçük Bir Deneme

★☆☆ quote.css'te thead { display: table-header-group; } satırını sil ve quote 60 komutunu tekrar çalıştır. İkinci ve üçüncü sayfada header row ne oluyor?

★★☆ Footer'daki Times sorununu çöz: footer'ı DisplayHeaderFooter yerine sayfanın içinde position: fixed bir element olarak yaz ve PdfPig ile font listesinin yalnız Lato'dan oluştuğunu doğrula. Sayfa numarasını bu yöntemle yazabiliyor musun?

★★★ ChromiumPdfRenderer'a, belirli sayıda belgeden sonra browser'ı kapatıp yeniden başlatan bir sayaç ekle. Bu sırada render edilmekte olan belgeleri nasıl beklersin?

PDF tarafını burada bitiriyoruz. Bir sonraki yazıda kitap kapaklarına geçiyoruz: yüklenen görseli SkiaSharp ve ImageSharp ile yeniden boyutlandırıyor, EXIF (fotoğrafın içine yazılan kamera, tarih ve konum bilgisi) verisini temizliyor, decompression bomb'a karşı piksel sınırı koyuyor ve ImageSharp 4'ün lisans anahtarı olmadan Release build'de neden durduğuna bakıyoruz.