Skip to main content

Text and Humanizing

These methods turn data into text that people read: messages from templates, counts with the right noun, previews, search highlights and diffs. They also cover a few checks and conversions on text that users type or that you store, such as card numbers, Roman numerals, short ids and compressed payloads.

All of them are extension methods on string in the System namespace, so no using is needed.

At a glance​

MethodWhat it does
FormatWithFills {Name} placeholders from an object's properties or a dictionary's keys.
PluralizeReturns the English plural of a word or phrase.
ToQuantityCombines a count with the singular or plural form: "3 orders".
IsValidCreditCardChecks the length and Luhn checksum of a card number.
FromRomanParses a standard Roman numeral into an int.
FromShortIdTurns a 22-character short id back into its Guid.
WrapWord-wraps text to a maximum line width.
ReverseTextReverses text without breaking emoji or accented letters.
WordCountCounts the words in a text.
ReadingTimeEstimates how long a text takes to read.
TruncateWordsKeeps the first N words and adds "...".
HighlightWraps every match of a search term in markers, HTML-safe by default.
DiffLinesCompares two texts line by line, like git diff.
CompressGZips text and returns it as Base64.
DecompressRestores text from Compress, with a size limit.
JSON formatting

PrettyJson and MinifyJson live in the same class but are documented on the JSON and safe logging page.

Templates and wording​

FormatWith​

FormatWith(object source, IFormatProvider? provider = null) fills named placeholders in a template. It works like string interpolation, but for templates stored as data: e-mail bodies, notification texts or messages kept in a database.

A placeholder is {Name}, {Name:format} or a dotted path such as {Customer.Name}. Each name is read from a public instance property of source, or from a key when source (or any object along the path) is a dictionary.

Invoice e-mail from an object
using System.Globalization;

Invoice invoice = new Invoice
{
Number = "INV-1042",
Customer = new Customer { Name = "Ann", Address = new Address { City = "Amman" } },
Total = 1234.5m,
DueDate = new DateTime(2024, 5, 17),
};

string template = "Invoice {Number} for {Customer.Name} ({Customer.Address.City}): {Total:N2}, due {DueDate:yyyy-MM-dd}.";

string body = template.FormatWith(invoice, CultureInfo.InvariantCulture);
// → "Invoice INV-1042 for Ann (Amman): 1,234.50, due 2024-05-17."

A dictionary works the same way. This suits values that come from configuration or a database row.

Values from a dictionary
Dictionary<string, object?> values = new Dictionary<string, object?>
{
["Name"] = "Ann",
["Count"] = 3,
};

string message = "{Name} has {Count} items in the cart".FormatWith(values);
// → "Ann has 3 items in the cart"

Formats. The text after the first : is the format string, so a format may itself contain colons. The format is applied only when the value is IFormattable (numbers, dates, Guid, enums, and so on).

A format that contains a colon
DateTime placedAt = new DateTime(2024, 5, 17, 13, 45, 0);

string line = "Placed {When:yyyy-MM-dd HH:mm}".FormatWith(new { When = placedAt }, CultureInfo.InvariantCulture);
// → "Placed 2024-05-17 13:45"

Culture. Without a provider, formats use the current culture. Pass a provider when the output must not depend on the machine.

Current culture versus an explicit provider
// Current culture is de-DE
string local = "Total: {Total:N2}".FormatWith(new { Total = 1234.5m });
// → "Total: 1.234,50"

string invariant = "Total: {Total:N2}".FormatWith(new { Total = 1234.5m }, CultureInfo.InvariantCulture);
// → "Total: 1,234.50"

Literal braces. Write {{ for { and }} for }, as in string.Format.

Escaping braces
string help = "{{Name}} is replaced by {Name}, }} too".FormatWith(new { Name = "Ann" });
// → "{Name} is replaced by Ann, } too"

Null values. A null value becomes an empty string, with or without a format. A path that passes through null also gives an empty string.

Null values
Dictionary<string, object?> values = new Dictionary<string, object?> { ["Name"] = null };

string missingName = "[{Name}] [{Name:N2}]".FormatWith(values);
// → "[] []"

string missingCity = "[{Address.City}]".FormatWith(new { Address = (object?)null });
// → "[]"
Errors
  • ArgumentNullException when template or source is null.
  • FormatException when a placeholder names a missing property or dictionary key ({Missing}, {Address.Missing}), when a placeholder is empty ({}), when a { is not closed ("Hi {Name"), or when a single } appears ("Hi } there").
note
  • Property names are matched case-sensitively. Dictionary keys use the dictionary's own comparer.
  • Only public instance properties are read. Fields and indexers are not.
  • Spaces around a name are ignored: { Name } is the same as {Name}.
  • A template with no placeholders comes back unchanged.
  • FormatWith uses reflection and is marked [RequiresUnreferencedCode]. See Trimming and AOT.
  • The reverse operation, pulling values out of text that matches a template, is ExtractWith on the String parsing page.

Pluralize​

Pluralize() returns the English plural of a singular word or phrase. It handles regular endings, a list of irregular words and a list of uncountable words. Only the last word of a phrase changes. The capitalization of the input is kept.

Plural forms
"item".Pluralize(); // → "items"
"box".Pluralize(); // → "boxes"
"church".Pluralize(); // → "churches"
"city".Pluralize(); // → "cities"
"day".Pluralize(); // → "days"
"person".Pluralize(); // → "people"
"child".Pluralize(); // → "children"
"knife".Pluralize(); // → "knives"
"sheep".Pluralize(); // → "sheep"
"credit card".Pluralize(); // → "credit cards"
Capitalization is kept
"Box".Pluralize(); // → "Boxes"
"BOX".Pluralize(); // → "BOXES"
"Person".Pluralize(); // → "People"
"PERSON".Pluralize(); // → "PEOPLE"
"CD-ROM".Pluralize(); // → "CD-ROMS"
note
  • Uncountable words such as "data", "software", "feedback", "information", "news" and "equipment" are returned unchanged.
  • An empty string returns an empty string.
  • null throws ArgumentNullException (parameter word).
  • The rules are English only. They are not affected by the current culture.

ToQuantity​

ToQuantity(int count) writes the count followed by the right form of the word. Use it for labels such as "1 file" and "3 files" so you do not have to write the if yourself.

Counts with the right noun
"order".ToQuantity(1); // → "1 order"
"order".ToQuantity(3); // → "3 orders"
"order".ToQuantity(0); // → "0 orders"
"person".ToQuantity(0); // → "0 people"
"person".ToQuantity(1); // → "1 person"
"item".ToQuantity(-1); // → "-1 item"
"item".ToQuantity(-2); // → "-2 items"
note
  • The singular is used for 1 and -1. Every other count, including 0, uses the plural from Pluralize.
  • The count is always written with the invariant culture, without digit grouping: "file".ToQuantity(12345) gives "12345 files" even when the current culture is de-DE.
  • The word is used as given for the singular, so pass the singular form.
  • null throws ArgumentNullException (parameter word).

Card numbers, Roman numerals and short ids​

IsValidCreditCard​

IsValidCreditCard() checks that the text looks like a payment card number: 12 to 19 digits that pass the Luhn checksum. Spaces and dashes between digits are allowed. Use it to catch typos in a checkout form before you call the payment provider.

Checking card numbers
"4111111111111111".IsValidCreditCard(); // → true
"4111 1111 1111 1111".IsValidCreditCard(); // → true
"4111-1111-1111-1111".IsValidCreditCard(); // → true
"5555555555554444".IsValidCreditCard(); // → true
"378282246310005".IsValidCreditCard(); // → true

"4111111111111112".IsValidCreditCard(); // → false (checksum fails)
"4111.1111.1111.1111".IsValidCreditCard(); // → false (dots are not allowed)
"4111a11111111111".IsValidCreditCard(); // → false
"00000000000".IsValidCreditCard(); // → false (11 digits)
"".IsValidCreditCard(); // → false
warning

A true result means the number is well-formed. It does not mean the card exists, is active or belongs to the user. All zeros of a valid length ("000000000000") also pass, because they satisfy the Luhn checksum.

note
  • A null receiver returns false and does not throw.
  • Only ASCII digits 0–9 count as digits.
  • To hide a card number in output, use Mask from String manipulation: "4111111111111111".Mask() gives "************1111".

FromRoman​

FromRoman() parses a Roman numeral into an int from 1 to 3999. It ignores case and surrounding white space. It accepts only the standard form, so "IIII" and "VX" are rejected.

Parsing Roman numerals
"MMXXIV".FromRoman(); // → 2024
"mmxxiv".FromRoman(); // → 2024
"McMxCiV".FromRoman(); // → 1994
" XIV ".FromRoman(); // → 14
"MMMCMXCIX".FromRoman(); // → 3999
Errors
  • FormatException for non-standard or invalid text: "IIII", "VX", "IIV", "MMMM" (4000 is out of range), "ABC", "X I", "" and white space only.
  • ArgumentNullException when the receiver is null (parameter roman).

The reverse, ToRoman() on an int (2024.ToRoman()), is on the Numbers page.

FromShortId​

FromShortId() turns a 22-character short id back into the Guid it came from. Short ids come from ToShortId() on a Guid (orderId.ToShortId()), which writes a Guid as URL-safe Base64 (letters, digits, - and _). Use the pair when you want shorter ids in URLs but still store a Guid.

Round-tripping an order id
Guid orderId = Guid.NewGuid();

string shortId = orderId.ToShortId(); // 22 characters, safe in a URL
Guid restored = shortId.FromShortId();

bool same = restored == orderId; // → true

bool isEmpty = "AAAAAAAAAAAAAAAAAAAAAA".FromShortId() == Guid.Empty; // → true
Errors

FromShortId throws FormatException when:

  • the text is not exactly 22 characters long;
  • it contains a character other than A–Z, a–z, 0–9, - or _ (so +, /, = and spaces are rejected);
  • it is not the canonical form. The last character carries 4 unused bits, so some 22-character strings, such as "AAAAAAAAAAAAAAAAAAAAAB", decode but are not what ToShortId would write. They are rejected, so each Guid has exactly one short id.

A null receiver throws ArgumentNullException (parameter shortId).

ToShortId and Guid.NewShortId() are covered on the Types and reflection page.

Layout and measuring text​

Wrap​

Wrap(int width) word-wraps text so that no line is longer than width characters. Use it for console output, plain-text e-mails or fixed-width reports.

Wrapping a product description
string description = "The quick brown fox jumps over the lazy dog";

string wrapped = description.Wrap(20);
Output
The quick brown fox
jumps over the lazy
dog

Existing line breaks are kept, including empty lines.

Line breaks are kept
string notes = "one two\r\nthree four five\n\nsix".Wrap(10);
Output
one two
three four
five

six

A word longer than the width is split into pieces of width characters.

Long words are split
string wrapped = "hi abcdefghij".Wrap(4);
Output
hi
abcd
efgh
ij
note
  • Lines in the result are joined with Environment.NewLine, whatever line endings the input used.
  • Inside a paragraph, runs of white space between words become a single space.
  • Text that already fits comes back unchanged: "short text".Wrap(80) gives "short text". An empty string gives an empty string.
  • width below 1 throws ArgumentOutOfRangeException. A null receiver throws ArgumentNullException (parameter text).

ReverseText​

ReverseText() reverses text by user-visible characters. A plain char reverse splits emoji, skin-tone modifiers, flags and combining accents into broken pieces. ReverseText keeps each of them whole.

Reversing text safely
"abc".ReverseText(); // → "cba"
"👍🏽 héllo".ReverseText(); // → "olléh 👍🏽"
"🇯🇴🇪🇬".ReverseText(); // → "🇪🇬🇯🇴"
"".ReverseText(); // → ""
note
  • Characters are grouped with StringInfo text elements. On .NET 5 and later these follow the Unicode extended grapheme cluster rules.
  • A null receiver throws ArgumentNullException (parameter text).

WordCount​

WordCount() counts the words in a text. A word is a run of non-white-space characters that contains at least one letter or digit, so stand-alone punctuation such as - or ... is not counted.

Counting words
"Hello, world!".WordCount(); // → 2
" one two\nthree\t four ".WordCount(); // → 4
"Hello - world ... !".WordCount(); // → 2
"it's 2026".WordCount(); // → 2
"-- ** !!".WordCount(); // → 0
"".WordCount(); // → 0

A null receiver throws ArgumentNullException (parameter text).

ReadingTime​

ReadingTime() estimates how long a text takes to read at 200 words per minute. ReadingTime(int wordsPerMinute) uses the speed you give. Both return a TimeSpan and use WordCount to count words.

A blog card label
string article = string.Join(" ", Enumerable.Repeat("word", 400));

double minutes = article.ReadingTime().TotalMinutes;
// → 2

string label = $"{Math.Ceiling(article.ReadingTime().TotalMinutes)} min read";
// → "2 min read"
A custom reading speed
string summary = string.Join(" ", Enumerable.Repeat("word", 50));

double seconds = summary.ReadingTime(100).TotalSeconds;
// → 30
note
  • The result is not rounded. Round it yourself for display, as with Math.Ceiling above.
  • An empty text returns TimeSpan.Zero.
  • wordsPerMinute below 1 throws ArgumentOutOfRangeException. A null receiver throws ArgumentNullException (parameter text), for both overloads.

TruncateWords​

TruncateWords(int maxWords) keeps the first maxWords words and adds "..." when it cut something. It never cuts inside a word. Use it for previews in cards, lists and notifications.

A review preview
string review = "The food was great and the service too";

string preview = review.TruncateWords(5);
// → "The food was great and..."
Edge cases
"one two".TruncateWords(5); // → "one two"
"one two".TruncateWords(2); // → "one two"
"one two three four".TruncateWords(2); // → "one two..."
"one two\n\nthree".TruncateWords(2); // → "one two..."
"one,\ttwo three".TruncateWords(1); // → "one,..."
" one two".TruncateWords(1); // → " one..."
"".TruncateWords(1); // → ""
note
  • Here a word is any run of non-white-space characters, so punctuation attached to a word stays with it ("one,"). This differs from WordCount, which skips tokens with no letter or digit.
  • When the text is cut, white space before the cut is removed and "..." (three dots) is added. When nothing is cut, the text is returned exactly as given, trailing white space included.
  • White space inside the kept part is not changed.
  • maxWords below 1 throws ArgumentOutOfRangeException. A null receiver throws ArgumentNullException (parameter text).
  • To take a fixed number of characters instead of words, see Left and Right on the String manipulation page.

Search highlights and diffs​

Highlight​

Highlight(string term, string before, string after, bool htmlEncode = true) wraps every occurrence of term in before and after. Matching ignores case, and each match keeps its original case. Use it to mark search terms in result snippets.

By default the text is HTML-encoded first, so the result is safe to render as HTML. Only your markers are markup.

Search results in HTML
string snippet = "Wireless mouse, mouse pad";

string html = snippet.Highlight("mouse", "<mark>", "</mark>");
// → "Wireless <mark>mouse</mark>, <mark>mouse</mark> pad"

string mixedCase = "Hello hello HELLO".Highlight("hello", "<mark>", "</mark>");
// → "<mark>Hello</mark> <mark>hello</mark> <mark>HELLO</mark>"
Markup in the text is encoded
"<b>bold</b> text".Highlight("text", "<mark>", "</mark>");
// → "&lt;b&gt;bold&lt;/b&gt; <mark>text</mark>"

"use <b> tags".Highlight("<b>", "<mark>", "</mark>");
// → "use <mark>&lt;b&gt;</mark> tags"

"Tom & Jerry".Highlight("&", "<b>", "</b>");
// → "Tom <b>&amp;</b> Jerry"

The term is matched against the raw text before encoding. A search for "amp" therefore never matches inside the &amp; entity.

Entities are never split
"Fish & chips, damp".Highlight("amp", "<b>", "</b>");
// → "Fish &amp; chips, d<b>amp</b>"

Pass htmlEncode: false for plain-text output, such as a console or a log.

Plain text
"<i>x</i> and x".Highlight("x", "[", "]", htmlEncode: false);
// → "<i>[x]</i> and [x]"
warning

before and after are inserted as given and are never encoded. Do not build them from user input.

note
  • When term is empty or not found, the result is the text alone: encoded when htmlEncode is true ("a < b" gives "a &lt; b"), unchanged when it is false.
  • Matches do not overlap. The search continues after the end of each match.
  • Matching is ordinal and ignores case. It does not depend on the current culture.
  • ArgumentNullException is thrown when text, term, before or after is null, with that parameter name.

DiffLines​

DiffLines(string newText) compares the receiver (the old text) with newText line by line and returns the result in a git diff style. Removed lines start with "- ", added lines with "+ ", and unchanged lines with two spaces. Use it to show what changed in a configuration file, a template or a generated report.

What changed in a config
string oldConfig = "name: api\nport: 80\nhost: local";
string newConfig = "name: api\nport: 8080\nhost: local";

string diff = oldConfig.DiffLines(newConfig);
Output
name: api
- port: 80
+ port: 8080
host: local

Common lines stay unchanged, even when lines are inserted and removed around them.

Insertions and removals
string diff = "a\nb\nc\nd".DiffLines("a\nx\nb\nd");
Output
a
+ x
b
- c
d

An empty text has no lines, and line endings are normalized before comparing.

Empty texts and line endings
string added = "".DiffLines("a\nb");
// → "+ a" and "+ b", one per line

string removed = "a\nb".DiffLines("");
// → "- a" and "- b", one per line

string nothing = "".DiffLines("");
// → ""

string sameText = "a\r\nb\rc".DiffLines("a\nb\nc");
// → " a", " b" and " c", one per line: only the line endings differ

Trailing white space is part of the line, so a change in it shows up.

Trailing white space counts
string diff = "foo".DiffLines("foo ");
// → "- foo" then "+ foo " (with the two trailing spaces)
note
  • Lines in the result are joined with Environment.NewLine. Input line endings (\r\n, \n, \r) are normalized before comparing.
  • An empty line is written as only its marker ("-" or "+"), or as an empty line when it is unchanged.
  • The diff is based on the longest common subsequence. Lines that match at the start and end are paired first, so small edits to large texts stay cheap. If the changed block in the middle is too large to align (more than 16 million table cells, for example 4,001 × 4,001 changed lines), it is shown as all old lines removed, then all new lines added. The output is still correct, just not minimal.
  • ArgumentNullException is thrown when oldText (the receiver) or newText is null.
  • To diff sequences of objects rather than lines of text, see Diff and dependencies.

Compression​

Compress​

Compress() encodes the text as UTF-8, compresses it with GZip and returns the bytes as Base64. Use it to store or send large, repetitive text, such as a JSON snapshot in a database column or a cache entry. Reverse it with Decompress.

Storing an order snapshot
string json = string.Concat(Enumerable.Repeat("{\"name\":\"value\",\"count\":42},", 200));

string compressed = json.Compress();

bool isMuchSmaller = compressed.Length < json.Length / 10; // → true
note
  • The result is standard Base64 (it may contain +, / and =), not URL-safe Base64.
  • Very short text can come out longer than the input, because GZip adds a header and Base64 adds about a third.
  • A null receiver throws ArgumentNullException (parameter text).

Decompress​

Decompress(long maxDecompressedBytes = 67108864) restores text produced by Compress. It stops once the decompressed output would exceed maxDecompressedBytes (64 MB by default). This protects you from a "zip bomb": a small hostile input that expands into gigabytes.

Round trip
string original = "مرحبا 👍🏽 héllo — ünïcödé";

string restored = original.Compress().Decompress();

bool same = restored == original; // → true
A tighter size limit for untrusted input
string payload = new string('a', 2000).Compress();

// Throws InvalidDataException: the output would be 2,000 bytes and the limit is 100.
string text = payload.Decompress(maxDecompressedBytes: 100);
tip

Set maxDecompressedBytes to the largest size you expect when the compressed text comes from outside your system, such as a request body or a message queue. Output of exactly the limit is allowed.

Errors
  • FormatException when the text is not valid Base64 ("not base64!").
  • InvalidDataException when the bytes are not GZip data, or when the output would exceed maxDecompressedBytes.
  • ArgumentNullException when the receiver is null (parameter compressed).

See also​