Zero-Dependency PDF Generation in .NET: Building PDFs Without External Libraries

Zero-Dependency PDF Generation in .NET: Building PDFs Without External Libraries

Most .NET developers reach for PdfSharp, iTextSharp, or commercial libraries when they need to generate PDFs. But what if you could create PDFs without any external dependencies? In this article, we explore two approaches: building your own minimal PDF generator from scratch using only the .NET Base Class Library (BCL), and using TerraPDF—a real, production-ready zero-dependency library.

Why Go Dependency-Free?

Before diving into implementation, let's understand why you might want to avoid third-party PDF libraries:

Two Approaches to Zero-Dependency PDFs

Approach Pros Cons Lines of Code
Build your own Full control, educational, custom-tailored Reinventing wheel, limited features, maintenance burden ~150-300
Use TerraPDF Production-ready, rich features, maintained External package (but zero-dep) ~30

Let's examine both.

Option 1: Building Your Own Minimal PDF Generator

A PDF file is essentially a structured binary file with these main components:

  1. Header: Identifies the file as PDF (%PDF-1.7)
  2. Body: Contains objects defining pages, fonts, content streams
  3. Cross-reference table: Byte offsets for quick object lookup
  4. Trailer: Points to root object and cross-reference table

Minimal Valid PDF

The smallest possible PDF displays "Hello World":

%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] /Contents 4 0 R /Resources << >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 12 Tf
50 50 Td
(Hello World) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000212 00000 n
trailer
<< /Size 5 /Root 1 0 R >>
startxref
290
%%EOF

Building a C# Generator

Here's a complete minimal PDF generator (~150 lines):

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

public class SimplePdfDocument
{
    private readonly List<PdfObject> objects = new();
    private int nextObjectId = 1;
    private readonly List<int> pageObjectIds = new();

    public int AddPage()
    {
        var page = new PdfDictionary
        {
            ["Type"] = "/Page",
            ["Parent"] = 0, // updated later
            ["MediaBox"] = new PdfArray { 0, 0, 595, 842 }, // A4
            ["Contents"] = 0,
            ["Resources"] = new PdfDictionary()
        };
        int id = AddObject(page);
        pageObjectIds.Add(id);
        return id;
    }

    public void SetPageContent(int pageId, string content)
    {
        var stream = new PdfStream(Encoding.ASCII.GetBytes(content));
        int streamId = AddObject(stream);
        var page = objects[pageId - 1] as PdfDictionary;
        page["Contents"] = streamId;
    }

    public string GenerateText(string text, float x, float y, float fontSize = 12)
    {
        return $"BT\n/F1 {fontSize} Tf\n{x} {y} Td\n({Escape(text)}) Tj\nET";
    }

    private string Escape(string s) => s.Replace("(", "\\(").Replace(")", "\\)");
    private int AddObject(PdfObject obj) { obj.Number = nextObjectId++; objects.Add(obj); return obj.Number; }

    public void Save(string path)
    {
        using var w = new StreamWriter(path, false, Encoding.ASCII);
        w.WriteLine("%PDF-1.4");
        var offsets = new List<long>();
        foreach (var obj in objects)
        {
            offsets.Add(w.BaseStream.Position);
            w.Write(obj.ToString());
            w.WriteLine();
        }
        long xrefPos = w.BaseStream.Position;
        w.WriteLine("xref"); w.WriteLine($"0 {objects.Count + 1}");
        w.WriteLine("0000000000 65535 f");
        foreach (var off in offsets) w.WriteLine(off.ToString("D10") + " 00000 n");
        w.WriteLine("trailer"); w.WriteLine($"<< /Size {objects.Count + 1} /Root 1 0 R >>");
        w.WriteLine("startxref"); w.WriteLine(xrefPos); w.WriteLine("%%EOF");
    }
}

public abstract class PdfObject { public int Number { get; set; } public abstract override string ToString(); }
public class PdfDictionary : PdfObject, Dictionary<string, object>
{
    private readonly Dictionary<string, object> d = new();
    public void Add(string k, object v) => d[k] = v;
    public override string ToString()
    {
        var sb = new StringBuilder();
        sb.Append($"{Number} 0 obj\n<<\n");
        foreach (var kv in d) sb.Append($"  {kv.Key} {Serialize(kv.Value)}\n");
        sb.Append(">>\nendobj"); return sb.ToString();
    }
    private string Serialize(object v) => v switch { string s when s.StartsWith("/") => s, string s => $"/{s}", int i => i.ToString(), PdfArray a => a.ToString(), _ => v.ToString()! };
}
public class PdfArray : List<object>
{
    public override string ToString()
    {
        var sb = new StringBuilder("[ ");
        for (int i = 0; i < Count; i++) sb.Append(i > 0 ? " " : "").Append(ToStringElement(this[i]));
        sb.Append(" ]"); return sb.ToString();
    }
    private string ToStringElement(object e) => e switch { int i => i.ToString(), string s => s.StartsWith("/") ? s : $"/{s}", _ => e.ToString()! };
}
public class PdfStream : PdfObject
{
    private readonly byte[] data; public int Length => data.Length;
    public PdfStream(byte[] d) => data = d;
    public override string ToString() => $"{Number} 0 obj\n<< /Length {Length} >>\nstream\n{Encoding.ASCII.GetString(data)}\nendstream\nendobj";
}

Usage — a functional invoice generator in ~30 lines of actual logic:

var doc = new SimplePdfDocument();
int pageId = doc.AddPage();

var sb = new StringBuilder();
sb.AppendLine(doc.GenerateText("INVOICE #12345", 50, 750, 16));
sb.AppendLine(doc.GenerateText("Acme Corp", 50, 730));
sb.AppendLine(doc.GenerateText("Date: 2025-06-15", 400, 750, 9));
sb.AppendLine(doc.GenerateText("Amount Due: $1,250.00", 400, 730, 9));
sb.AppendLine(doc.GenerateText("Thank you for your business!", 220, 100, 10));

doc.SetPageContent(pageId, sb.ToString());
doc.Save("invoice.pdf");

That's 5 lines of setup, 5 lines of content building, 2 lines of save — 12 lines total for a working invoice.

Limitations of DIY PDF Generation

While educational and lightweight, roll-your-own has hard limits:

  1. Fonts: Only basic 14 PDF fonts (Helvetica, Times, Courier, Symbol, ZapfDingbats) are natively supported. Custom fonts require encoding font programs into PFB/PFM formats—extremely complex.
  2. Images: You must manually convert PNG/JPEG to DCT/Flate streams with proper filters and decode parameters.
  3. Unicode: Requires CID fonts and CMaps; pages of mapping tables.
  4. Compression: Implementing DEFLATE (Flate) compression by hand is non-trivial.
  5. PDF/A: XMP metadata, font embedding mandates, colour profiles—do not attempt without serious effort.
  6. Complex graphics: Bézier curves, transparency groups, shadings—mathematically heavy.

Option 2: TerraPDF — Production-Ready Zero-Dependency

TerraPDF is a real, open-source (MIT) library that is genuinely zero-dependency: no external runtime packages, no native binaries, just a single managed DLL. It ships with .NET 8+ targeted builds and works cross-platform.

Installation

🚀 TerraPDF NuGet Package: https://www.nuget.org/packages/TerraPDF/

dotnet add package TerraPDF

That's it — no transitive dependencies pulled in. Verify with:

dotnet list package --include-transitive
# Output: No transitive dependencies found.

Real TerraPDF Invoice in 30 Lines

using TerraPDF.Core;
using TerraPDF.Helpers;

public record Invoice(string Number, DateTime Date, string Customer, List<Item> Items)
{
    public decimal Total => Items.Sum(i => i.LineTotal);
}
public record Item(string Desc, int Qty, decimal UnitPrice) { public decimal LineTotal => Qty * UnitPrice; }

public static byte[] GenerateInvoice(Invoice inv)
{
    return Document.Create(doc =>
    {
        doc.Page(page =>
        {
            page.Size(PageSize.A4);
            page.Margin(2, Unit.Centimetre);
            page.DefaultTextStyle(s => s.FontSize(10));

            page.Header().Column(h =>
            {
                h.Item().Text("INVOICE").Bold().FontSize(22).FontColor("#1a4a8a");
                h.Item().Row(r =>
                {
                    r.RelativeItem().Text($"#: {inv.Number}  |  Date: {inv.Date:yyyy-MM-dd}");
                    r.AutoItem().AlignRight().Text($"Due: {inv.Date.AddDays(30):yyyy-MM-dd}");
                });
                h.Item().LineHorizontal(1, "#1a4a8a");
            });

            page.Content().Column(c =>
            {
                // Customer info
                c.Item().Row(r =>
                {
                    r.RelativeItem().Text("From: Your Company\n123 Main St");
                    r.RelativeItem().Text($"Bill To:\n{inv.Customer}");
                });

                // Table
                c.Item().Table(t =>
                {
                    t.ColumnsDefinition(cols =>
                    {
                        cols.RelativeColumn(4); // Description
                        cols.RelativeColumn(1); // Qty
                        cols.RelativeColumn(2); // Price
                        cols.RelativeColumn(2); // Total
                    });

                    t.HeaderRow(row =>
                    {
                        row.Cell().Background("#1a4a8a").Padding(5)
                           .Text("Description").Bold().FontColor(Color.White);
                        row.Cell().Background("#1a4a8a").Padding(5).AlignCenter()
                           .Text("Qty").Bold().FontColor(Color.White);
                        row.Cell().Background("#1a4a8a").Padding(5).AlignRight()
                           .Text("Price").Bold().FontColor(Color.White);
                        row.Cell().Background("#1a4a8a").Padding(5).AlignRight()
                           .Text("Total").Bold().FontColor(Color.White);
                    });

                    foreach (var item in inv.Items)
                    {
                        t.Row(row =>
                        {
                            row.Cell().Padding(5).Text(item.Desc);
                            row.Cell().Padding(5).AlignCenter().Text(item.Qty.ToString());
                            row.Cell().Padding(5).AlignRight().Text($"{item.UnitPrice:C}");
                            row.Cell().Padding(5).AlignRight().Text($"{item.LineTotal:C}");
                        });
                    }
                });

                // Totals block
                c.Item().Row(r =>
                {
                    r.RelativeItem(7); // spacer
                    r.RelativeItem(3).Column(tot =>
                    {
                        tot.Item().LineHorizontal(1, Color.Grey.Lighten2);
                        tot.Item().Row(tr =>
                        {
                            tr.RelativeItem().Text("Grand Total:");
                            tr.RelativeItem().AlignRight().Text($"{inv.Total:C}").Bold();
                        });
                    });
                });

                // Footer message
                c.Item().AlignCenter()
                  .Text("Thank you for your business!").FontSize(9).FontColor(Color.Grey.Medium);
            });

            page.Footer().AlignCenter().Text(t =>
            {
                t.Span("Page "); t.CurrentPageNumber();
                t.Span(" of "); t.TotalPages();
            });
        });
    }).PublishPdf();
}

Usage:

var inv = new Invoice(
    Number: "INV-2025-001",
    Date: DateTime.Today,
    Customer: "Acme Corp\n123 Business Rd\nMetropolis, NY",
    Items: new()
    {
        new("Web Development", 40, 150m),
        new("Server Maintenance", 10, 100m),
        new("UI/UX Design", 20, 120m),
    });

byte[] pdf = GenerateInvoice(inv);
File.WriteAllBytes("invoice.pdf", pdf);

Actual line count (excluding braces, usings, record definitions): ~30 lines of generation logic inside the Document.Create callback.

What Makes TerraPDF "Zero-Dependency"?

TerraPDF's implementation uses only .NET BCL types:

This means:

Comparison: DIY vs TerraPDF

Aspect DIY (~150 LOC) TerraPDF
Development time 2-4 hours 30 minutes
Feature completeness Minimal Rich (tables, images, TOC)
Maintenance burden You own all bugs Maintained upstream
PDF spec compliance Basic (1.4 subset) 1.7 (good subset)
Multi-page support Manual Automatic
Table support Manual Auto-layout with repeating headers
Image support Basic (manual) Automatic sizing/positioning
Text wrapping Manual line breaking Automatic
Pagination Manual page breaks Automatic
Headers/Footers Manual per page Built-in per-page
Page numbers Manual counting Built-in current/total
Production readiness ❌ Educational only ✅ Yes

When to Build Your Own

Build your own minimal generator when:

Example where DIY wins: A 50-line thermal receipt generator for a local store's Raspberry Pi till that just needs: item × qty = total repeated 5 times, plus totals. No images, no tables, nothing fancy. The entire generator fits in one file, no NuGet, no dependencies. Perfect.

When to Use TerraPDF

Use TerraPDF when:

TerraPDF hits the sweet spot: MIT-licensed, zero external dependencies, modern fluent API, and sufficient features for 80% of PDF generation use cases.

When to Use a Different Library

Stick with established alternatives when you need:

Complete Example: Zero-Dep Invoice (Both Approaches Side-by-Side)

DIY (150 lines, manual):

// Manual page break logic, manual table cell positioning
float y = 700;
foreach (var item in items)
{
    if (y < 100) { AddNewPage(); y = 750; }
    DrawText(item.Desc, 50, y);
    DrawText(item.Qty.ToString(), 400, y);
    DrawText($"{item.Price:C}", 470, y);
    DrawText($"{item.Total:C}", 540, y);
    y -= 18;
}

TerraPDF (30 lines, automatic):

page.Content().Column(c =>
{
    c.Item().Table(t =>
    {
        t.ColumnsDefinition(cols =>
        {
            cols.RelativeColumn(4); cols.RelativeColumn(1);
            cols.RelativeColumn(2); cols.RelativeColumn(2);
        });
        t.HeaderRow(r =>
        {
            r.Cell().Background(brand).Padding(5).Text("Description").Bold().FontColor(Color.White);
            // ... other headers
        });
        foreach (var item in items)
            t.Row(r =>
            {
                r.Cell().Padding(5).Text(item.Desc);
                r.Cell().Padding(5).AlignCenter().Text(item.Qty.ToString());
                // ...
            });
    });
});

Key difference: TerraPDF handles pagination automatically—the table splits across pages and repeats headers without any extra code.

Recommendation

For zero-dependency PDF generation in .NET:

  1. Try TerraPDF first — It's genuinely zero-dep, MIT-licensed, and covers 80% of use cases with a clean API. Install with dotnet add package TerraPDF and you're done.

  2. Build your own only for toy problems — Educational, tiny one-offs, or when you absolutely cannot have any external package (even MIT-licensed).

  3. Reach for heavier libraries only when needed — iText for editing/signatures, Aspose for enterprise features, QuestPDF for complex declarative layouts.

The "zero-dependency" space in .NET PDF has changed: TerraPDF makes DIY PDF generation largely unnecessary for real projects. You get zero dependencies plus a full-featured API, which is the best of both worlds.

Use the DIY approach to learn, then switch to TerraPDF for production.