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:
- Reduced deployment size: No DLLs to ship with your application
- No licensing concerns: Avoid AGPL or commercial license complications
- Improved security: Fewer dependencies means fewer vulnerability vectors
- Full control: Customize every aspect of PDF generation
- Learning opportunity: Deep understanding of the PDF format
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:
- Header: Identifies the file as PDF (
%PDF-1.7) - Body: Contains objects defining pages, fonts, content streams
- Cross-reference table: Byte offsets for quick object lookup
- 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:
- 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.
- Images: You must manually convert PNG/JPEG to DCT/Flate streams with proper filters and decode parameters.
- Unicode: Requires CID fonts and CMaps; pages of mapping tables.
- Compression: Implementing DEFLATE (Flate) compression by hand is non-trivial.
- PDF/A: XMP metadata, font embedding mandates, colour profiles—do not attempt without serious effort.
- 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:
System.IOfor file/stream operationsSystem.Textfor encoding- No
System.Drawing.Common(already deprecated on non-Windows) - No native interop (no libgdiplus, no ICU)
- Single managed DLL (~150 KB)
This means:
- ✅ Works on Windows, Linux, macOS identically
- ✅ Works in Docker containers without extra apt-get installs
- ✅ Works in AWS Lambda, Azure Functions, Google Cloud Run
- ✅ No Visual C++ Redistributable requirements
- ✅ No ICU or fontconfig native libraries needed
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:
- Learning: Understanding PDF structure is the goal
- Tiny footprint: Sub-100 line generator fits your single-use case (e.g., thermal receipt with 3 lines)
- No third-party code allowed: Air-gapped environments, extreme compliance
- Custom format: Need non-standard PDF extensions or integrations
- Educational demos: Teaching file format internals
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:
- Production document generation: Invoices, reports, certificates
- Multi-page documents: Needs automatic pagination
- Tables: Any tabular data with headers
- Images/logos: Corporate branding
- Professional layout: Margins, padding, headers/footers
- MIT license requirement: Commercial closed-source
- Cross-platform: Docker, Linux servers
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:
- PDF editing/modifying: iText (commercial), PdfSharp (some manipulation)
- Digital signatures: iText, Aspose.PDF (commercial)
- PDF/A/PDF/UA compliance: iText, callas pdfToolbox (commercial)
- Ultra-complex layouts: QuestPDF (more layout features), SelectPdf
- ASP.NET Core integration: Rotativa (HTML→PDF), DinkToPdf (wkhtmltopdf wrapper)
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:
-
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 TerraPDFand you're done. -
Build your own only for toy problems — Educational, tiny one-offs, or when you absolutely cannot have any external package (even MIT-licensed).
-
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.