Skip to content

Fonts and text in PDF with C# and .NET

Since 1.1View as Markdown

Text on PDF pages: the standard fonts, embedded TrueType and OpenType fonts, wrapped and justified paragraphs, and right to left scripts.

DrawString writes one line of text at a baseline. PdfStandardFont gives the 14 fonts every PDF viewer has, with nothing to embed. PdfOpenTypeFont.Load reads a .ttf, .otf, .ttc, .woff or .woff2 file and embeds only the glyphs you draw.

// The 14 standard fonts need no embedding: every PDF viewer has them.
PdfFont bold = PdfStandardFont.HelveticaBold;
g.DrawString("Invoice 2026-114", bold, 24, PdfColor.Black, 40, 60); // x, then the baseline y
// Measure to align: right edge at 555 points.
double width = bold.MeasureString("Total: €1,250.00", 14);
g.DrawString("Total: €1,250.00", bold, 14, PdfColor.Black, 555 - width, 60);

Text in Helvetica and Inter, an Arabic paragraph with an amount in euros, and justified Arabic with kashida

DrawString(text, font, size, brush, x, y) places the start of the baseline at (x, y). To place the top of the line instead, add font.Ascent * size to y. Another overload takes a pen and a brush, so glyphs can be filled with a gradient, outlined, or both.

MeasureString(text, size) returns the width in points, which is all you need to right align or center a line. Font metrics are fractions of the font size: multiply by the size to get points.

MemberMeaning
Ascent, DescentHeight above and depth below the baseline used for line spacing (descent is positive)
LineGap, GetLineHeight(size)Recommended extra space, and the default line height in points
CapHeight, XHeightHeight of flat capitals and of flat lowercase letters
UnderlinePosition, UnderlineThicknessWhere an underline sits
StrikeoutPosition, StrikeoutThicknessWhere a strikethrough sits
HasGlyph(codePoint), CanRender(text)Whether the font can draw a character or a string

PdfStandardFont has Helvetica, Times and Courier with their bold and italic styles, Symbol and ZapfDingbats. Viewers supply them, so nothing is embedded.

  • Latin text uses WinAnsiEncoding (Windows-1252). Up to 37 further characters per font and document, such as Central European letters and the minus sign, get codes through a /Differences array.
  • Symbol and ZapfDingbats take their own Unicode characters.
  • A character the font cannot draw throws an ArgumentException. For other scripts, embed a font.
// Any TrueType or OpenType font: .ttf, .otf, .ttc, .woff or .woff2. Only the glyphs drawn are embedded.
PdfFont inter = PdfOpenTypeFont.Load("fonts/Inter-Regular.woff2");
g.DrawString("Inter, embedded as a subset, kerned and searchable.", inter, 14, PdfColor.FromHex("#1E3A8A"), 40, 100);
  • TrueType and OpenType (CFF) fonts load from .ttf, .otf, .ttc and .otc collections, WOFF and WOFF2. For a collection, pass the index of the font; CountFonts says how many it holds.
  • Drawn text embeds a subset of the glyphs used, as a Type 0 font with a ToUnicode map, so the text stays searchable and copyable.
  • Pairs are kerned from the font’s GPOS or kern table.
  • A font is written into a document the first time text is drawn with it, once per document. One PdfFont instance serves every size and can be shared between documents and threads.
  • A font whose license forbids embedding (the OS/2 fsType flags) is refused when first drawn. A damaged file raises FontFormatException.

A variable font holds a range of styles along design axes such as weight (wght) or width (wdth). Drawn as loaded, it shows its default instance. IsVariable, VariationAxes and NamedInstances describe it:

var font = PdfOpenTypeFont.Load("fonts/Variable.ttf");
Console.WriteLine($"Variable: {font.IsVariable}");
foreach (PdfFontAxis axis in font.VariationAxes)
{
Console.WriteLine($"{axis.Tag} ({axis.Name}): {axis.Minimum} to {axis.Maximum}, default {axis.Default}");
}
Console.WriteLine($"Named instances: {string.Join(", ", font.NamedInstances)}");

GetInstance(name) gives a named instance, such as “Bold”, and GetInstance(axisValues) any point in the design space. Axes left out keep their default, and values outside an axis’s range are clamped to it. Each instance is a PdfOpenTypeFont embedded as a static font, subset like any other.

PdfOpenTypeFont thin = font.GetInstance("Thin");
PdfOpenTypeFont bold = font.GetInstance("Bold");
PdfOpenTypeFont custom = font.GetInstance(new Dictionary<string, double> { ["wght"] = 550 });
g.DrawString("HOT TO", thin, 40, PdfColor.Black, 40, 70);
g.DrawString("HOT TO", font, 40, PdfColor.Black, 40, 130); // the default instance
g.DrawString("HOT TO", custom, 40, PdfColor.Black, 40, 190);
g.DrawString("HOT TO", bold, 40, PdfColor.Black, 40, 250);

The same words drawn from one variable font at the Thin, Regular, 550 and Bold weights

  • TrueType outlines move by their gvar deltas, advances come from HVAR, font metrics from MVAR, and control values from cvar, through the avar mapping. The weight and width classes and the italic angle follow the axes.
  • CFF2 fonts load at their default instance and are converted to CID keyed CFF for embedding; instances apply their blends.
  • Shaping applies the variation deltas of kerning and mark positions, and GSUB and GPOS feature variations, so an instance can swap glyphs.
  • Outlines, advances and metrics match the fontTools instancer, and shaping matches HarfBuzz, on the synthetic fixtures and variable fonts the test suite compares. Damaged variation data raises FontFormatException.

PdfFontCollection finds fonts by family, weight and style the way CSS matches them. Adding a file or a directory reads only the font’s names and style; the font itself loads when first found. PdfFontCollection.System holds the fonts installed on the machine.

var fonts = new PdfFontCollection();
fonts.AddDirectory("fonts"); // reads names and styles only
PdfFont? found = fonts.Find("Inter", weight: 700); // null when the family is unknown
PdfFont interBold = found ?? PdfStandardFont.HelveticaBold;
PdfFont arabic = fonts.Find("Noto Sans Arabic") ?? throw new InvalidOperationException("Font missing");

Find(family, weight, italic) takes a CSS weight from 1 to 1000 (400 regular, 700 bold) and returns the closest face, or null when the family is unknown. The collection is thread safe.

PdfParagraph holds rich text: spans, each with its own PdfTextStyle. DrawText lays the paragraph out at a width, wraps and aligns its lines, and draws it with its top left corner at (x, y). It returns the PdfTextLayout, whose Height tells how much room it took.

var body = new PdfTextStyle(inter, 11);
var paragraph = new PdfParagraph { Alignment = PdfTextAlignment.Justify, LineHeight = 1.5 }
.Add("Payment is due within ", body)
.Add("30 days", body with { Font = interBold })
.Add(" of the invoice date. Late payments carry interest at the legal rate, and reminders are sent ", body)
.Add("by email", body with { Underline = true, Brush = PdfColor.FromHex("#2563EB") })
.Add(". Thank you for your business.", body);
PdfTextLayout layout = g.DrawText(paragraph, 40, 130, 300); // top left corner and width
PdfParagraphDefaultNotes
AlignmentStartStart, End, Left, Right, Center or Justify (the last line aligns to the start)
DirectionAutoFrom the first strong character, or LeftToRight, RightToLeft
LineHeightnullA multiple of each span’s font size, as CSS line-height takes a number; null uses the font’s own spacing
FirstLineIndent0In points
PdfTextStyleDefaultNotes
Font, SizeRequired
BrushblackFill of the glyphs and decorations; null for outlines only
PennullOutline of the glyphs
FallbacksnoneFonts tried in order for characters Font lacks
LetterSpacing, WordSpacing0Extra space in points
Underline, StrikethroughfalseDecorations

PdfTextStyle is a record, so body with { Font = interBold } derives a variant.

Lines break at Unicode line break opportunities (UAX #14). A word wider than the line breaks between characters. Line feeds start a new line, tabs advance four spaces, and soft hyphens (U+00AD) show a hyphen when a word breaks there.

Hyphenation is off by default. Give a paragraph a PdfHyphenator and words break at the line’s end where the language allows, with a hyphen. The core library has Liang’s algorithm, as TeX defines it, and no patterns; the optional PdfNetStack.Hyphenation package has them.

// Opt in per paragraph. A language loads on first use; the hyphenator is shared and thread safe.
var german = new PdfParagraph { Alignment = PdfTextAlignment.Justify, Hyphenator = PdfHyphenationPatterns.ForLanguage("de") }
.Add("Die Rechtschreibung regelt die Silbentrennung, auch im Bundesverfassungsgericht.", new PdfTextStyle(PdfStandardFont.Helvetica, 11));
g.DrawText(german, 50, 60, 160);
TagLanguageTagLanguage
en-us (also en, en-CA)English, Americanen-gb (also en-AU, en-NZ, en-IE, en-ZA, en-IN)English, British
de (also de-AT, de-CH)German, reformed spellingfrFrench
nlDutchesSpanish
itItalianpt (also pt-BR, pt-PT)Portuguese
afAfrikaansbeBelarusian
bgBulgariancyWelsh
daDanishel (el-monoton), el-polytonGreek
etEstonianeuBasque
gaIrishglGalician
hrCroatianlaLatin
ltLithuaniannb (also no), nnNorwegian
plPolishskSlovak
slSlovenesqAlbanian
ukUkrainian

Patterns come from TeX’s hyph-utf8. Only sets with a permissive license ship, so some languages have no patterns: ca, cs, eo, fi, hu, hy, id, is, lv, mk, ro, ru, sr, sv, th and tr, with German in the 1901 spelling (de-1901), which has its own breaks. ForLanguage returns null for those. If you are licensed to use other patterns, PdfHyphenator.Create takes them in TeX’s format.

Each set brings the minimum letters before and after a hyphen its source gives. MinWordLength is 5 by default. Words the patterns get wrong, or that must never break, go in as exceptions:

// Tune the limits, and add your own words: a word without a hyphen never breaks.
PdfHyphenator english = PdfHyphenationPatterns.ForLanguage("en-GB")!.WithExceptions(["PdfNetStack", "ta-ble"]) with { MinWordLength = 6, MinLeft = 3 };
string marked = english.InsertSoftHyphens("Characterisation of programmes"); // soft hyphens at each point
Console.WriteLine(string.Join("|", english.FindBreaks("Characterisation")));

Left alone: words with a soft hyphen, whose breaks are the author’s; words joined to a digit, or holding a capital past the first letter; words of all capitals, unless HyphenateAllCaps; web and e-mail addresses and paths; words over 64 letters. Results do not depend on the current culture. PdfParagraph.Text keeps words whole, and extracted text marks a shown hyphen with U+00AD. In HTML, see hyphens: auto.

Text runs through the Unicode bidirectional algorithm (UAX #9) and an OpenType shaper: Arabic and Persian letters join and take their contextual forms, ligatures form, vowel marks sit on their letters, and mixed right to left and left to right text is ordered with mirrored brackets. The line breaking and bidi implementations pass the Unicode 16 conformance suites, and the shaper matches HarfBuzz on the fonts the test suite compares.

var arabicStyle = new PdfTextStyle(arabic, 16) { Fallbacks = [inter] }; // digits and € come from Inter
var rtl = new PdfParagraph { Direction = PdfTextDirection.RightToLeft }
.Add("هذا نص عربي يقرأ من اليمين إلى اليسار. المبلغ المستحق: 1,250.00 €", arabicStyle);
g.DrawText(rtl, 40, 140 + layout.Height + 20, 300); // start aligned: on the right

In a right to left paragraph, Start alignment means the right edge. Shaped and right to left text carries ActualText, so it copies and searches in logical order.

With Kashida = true and Justify alignment, justified Arabic lines elongate joined letters with tatweel before the spaces widen: one elongation per word, up to three tatweel widths, at the point Arabic typography prefers. Tatweels carry no text, so copying gives the original words. A font without a tatweel glyph justifies with spaces only. It is off by default.

// Justified Arabic lines elongate one join per word with tatweel before spaces widen.
var justified = new PdfParagraph { Direction = PdfTextDirection.RightToLeft, Alignment = PdfTextAlignment.Justify, Kashida = true }
.Add("هذا نص عربي يضبط على الجانبين بالكشيدة، فتطول الحروف المتصلة قبل أن تتسع المسافات بين الكلمات.", arabicStyle);
g.DrawText(justified, 40, 420, 300);

Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, Telugu, Kannada and Malayalam are shaped per Microsoft’s script development specifications: syllables are found, the base consonant, reph and pre-base matras reorder, the font’s basic and presentation forms apply in order, and broken clusters get a dotted circle. Fonts built to the old (deva) and the new (dev2) specification both work. The shaper matches HarfBuzz on a synthetic Devanagari and Bengali fixture and on a font covering all nine scripts. Draw with a font that covers the script, through DrawString, DrawText or a fallback in PdfTextStyle.Fallbacks.

The shaper normalizes text as HarfBuzz does. A character the font lacks is decomposed into ones it has, marks are sorted into canonical order, and a base and a mark are composed when the font has the composite glyph. Spaces and the non-breaking hyphen draw with the space and hyphen glyphs when the font has no glyph of their own. CanRender, font fallback and HTML text use the same coverage rules, so “é” typed as e plus a combining accent draws in a font that only has the precomposed letter.

PdfTextLayout.Create measures a paragraph without drawing it. Each line reports its Top, Baseline and Height, so you can decide how many lines fit, then draw a range of lines with DrawText(layout, x, y, first, count) and continue on the next page.

var style = new PdfTextStyle(PdfStandardFont.TimesRoman, 12);
var paragraph = new PdfParagraph { Alignment = PdfTextAlignment.Justify }.Add(text, style);
PdfTextLayout layout = PdfTextLayout.Create(paragraph, 595 - 2 * 72); // measure before drawing
PdfDocument doc = PdfDocument.Create();
const double top = 72, bottom = 842 - 72;
int next = 0;
while (next < layout.Lines.Count)
{
// As many lines as fit between the margins, measured from the first line of this page.
double pageTop = layout.Lines[next].Top;
int count = 0;
while (next + count < layout.Lines.Count
&& layout.Lines[next + count].Top + layout.Lines[next + count].Height - pageTop <= bottom - top)
{
count++;
}
PdfPage page = doc.Pages.Add(PdfRectangle.A4);
using PdfGraphics g = page.CreateGraphics();
g.DrawText(layout, 72, top, next, count);
next += count;
}
doc.Save("flow.pdf");
Console.WriteLine($"{layout.Lines.Count} lines on {doc.Pages.Count} pages");
  • PdfFontCollection and HTML do not choose variable font instances yet: they draw a variable font at its default instance. Use GetInstance for other styles.
  • Southeast Asian scripts (the Universal Shaping Engine), Khmer, Myanmar, Thai and Hangul shapers are on the roadmap. Lines are filled greedily.
  • A hyphenation point inside a ligature is skipped, since breaking there would need the word shaped again. Fonts without a hyphen glyph do not hyphenate. One hyphenator serves a whole paragraph; for mixed languages, use InsertSoftHyphens span by span.
  • Standard fonts cover Windows-1252 plus up to 37 further characters per font and document.

Questions

Is text drawn by PdfNetStack searchable?
Yes. Embedded fonts get a ToUnicode map, so text selects, copies and searches in any script the font covers. Shaped and right to left text also carries ActualText, so it copies in logical order.
Does embedding a font make the file large?
No. Only the glyphs you draw are embedded, as a subset. The 14 standard fonts are not embedded at all, so a page of text in them weighs about 2 KB.
Can I write Arabic, Hebrew or Hindi?
Yes. Text runs through the Unicode bidirectional algorithm and an OpenType shaper with Arabic and Indic shaping, so Arabic letters join, Devanagari syllables reorder and mixed directions are ordered. Use a font that has the script's glyphs.
Are variable fonts supported?
Yes. PdfOpenTypeFont.GetInstance gives a named instance or any axis values of a TrueType or CFF2 variable font, embedded as a static font.