omy.Utils.NumberToString 2.0.0-rc.1

This is a prerelease version of omy.Utils.NumberToString.
dotnet add package omy.Utils.NumberToString --version 2.0.0-rc.1
                    
NuGet\Install-Package omy.Utils.NumberToString -Version 2.0.0-rc.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="omy.Utils.NumberToString" Version="2.0.0-rc.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="omy.Utils.NumberToString" Version="2.0.0-rc.1" />
                    
Directory.Packages.props
<PackageReference Include="omy.Utils.NumberToString" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add omy.Utils.NumberToString --version 2.0.0-rc.1
                    
#r "nuget: omy.Utils.NumberToString, 2.0.0-rc.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package omy.Utils.NumberToString@2.0.0-rc.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=omy.Utils.NumberToString&version=2.0.0-rc.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=omy.Utils.NumberToString&version=2.0.0-rc.1&prerelease
                    
Install as a Cake Tool

omy.Utils.NumberToString

Number-to-string conversion for multiple languages and cultures, with support for ordinals, morphological variants (gender, case…), and currency amounts.

Install

This package's first publication is the 2.0.0-rc.1 release candidate - there is no earlier stable version, so dotnet add package requires an explicit version (NuGet does not install a prerelease by default):

dotnet add package omy.Utils.NumberToString --version 2.0.0-rc.1

Supported frameworks

  • net8.0

Supported cultures

Code Language Ordinals Variants
EN, EN-uk, EN-us English ✓ — (numbers are invariable)
FR, FR-fr, FR-ca French ✓ gender (masculin/feminin)
FR-be, FR-ch Belgian/Swiss French ✓ gender (masculin/feminin)
DE, de-DE, de-AT German (standard) ✓ genus (maskulin/feminin/neutrum) × kasus (nominativ/akkusativ/dativ/genitiv)
de-CH, de-LI Swiss/Liechtenstein German ✓ (same as DE; "ein tausend" not contracted to "tausend")
ES Spanish ✓ gender (masculino/femenino)
IT Italian ✓ gender (maschile/femminile)
PT Portuguese ✓ gender (masculino/feminino)
PL Polish ✓ rodzaj (maskulin/feminin/nijaki/plural_mos/plural) × przypadek (mianownik/dopełniacz/…)
NL Dutch ✓ — (numbers are invariable)
RO Romanian — gen (masculin/feminin)
RU Russian ✓ —
AR Arabic ✓ (1–19) gender (muzakkar/muʾannath)
HE Hebrew ✓ gender (standalone/zachar/nekeva)
ZH Chinese ✓ (prefix 第) — (no inflection)
JA Japanese ✓ (prefix 第) — (no inflection)
KO Korean ✓ (prefix 제) — (no inflection)
HI Hindi ✓ gender (strī) ordinals only
EL Greek ✓ gender (αρσενικό/θηλυκό/ουδέτερο)
FI Finnish ✓ sijamuoto (nominatiivi/partitiivi/genetiivi)
CA Catalan ✓ gender (masculí/femení)
EU Basque ✓ — (no grammatical gender)
GL Galician ✓ gender (masculino/feminino)
ZU Zulu — — (not yet implemented)
EE Ewe ✓ (prefix etsõ) —
WO Wolof ✓ —
HR Croatian ✓ — (numbers are invariable)
HU Hungarian ✓ — (numbers are invariable)
VN, VI, VI-VN Vietnamese ✓ — (numbers are invariable)
TR, TR-TR Turkish — — (numbers are invariable)
SV, SV-SE Swedish — — (numbers are invariable)
NO, NB, NB-NO Norwegian (Bokmål) — — (numbers are invariable)
UK, UK-UA Ukrainian — — (numbers are invariable)
DA, DA-DK Danish — — (numbers are invariable)
CS, CS-CZ Czech — — (numbers are invariable)
SK, SK-SK Slovak — — (numbers are invariable)
BG, BG-BG Bulgarian — — (numbers are invariable)
ID, MS Indonesian / Malay — — (numbers are invariable)
FA, FA-IR Persian (Farsi) — — (numbers are invariable)
SW Swahili — — (numbers are invariable)

Basic conversion

using Utils.NumberToString;

NumberToStringConverter en = NumberToStringConverter.GetConverter("EN");
NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");
NumberToStringConverter de = NumberToStringConverter.GetConverter("DE");

en.Convert(42);         // "forty-two"
en.Convert(-7);         // "minus seven"
en.Convert(1_000_000);  // "one million"

fr.Convert(21);         // "vingt et un"
fr.Convert(1_000_000);  // "un million"

de.Convert(1);          // "eins"
de.Convert(1_000_000);  // "eine Million"   ← GermanNumberToStringLanguageSpecifics

GetConverter falls back to the language code when a region variant is not found, then to "EN" as final default.


Ordinals

NumberToStringConverter en = NumberToStringConverter.GetConverter("EN");

en.ConvertOrdinal(1);    // "first"
en.ConvertOrdinal(2);    // "second"
en.ConvertOrdinal(3);    // "third"
en.ConvertOrdinal(21);   // "twenty-first"
en.ConvertOrdinal(100);  // "one hundredth"
en.ConvertOrdinal(-5);   // "minus fifth"
NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");

fr.ConvertOrdinal(1);    // "premier"       ← whole-number exception
fr.ConvertOrdinal(2);    // "deuxième"
fr.ConvertOrdinal(5);    // "cinquième"     ← word rule: cinq → cinquième
fr.ConvertOrdinal(9);    // "neuvième"      ← word rule: neuf → neuvième
fr.ConvertOrdinal(21);   // "vingt et unième"
fr.ConvertOrdinal(1000); // "millième"      ← removeTrailing="e" + suffix ième
NumberToStringConverter frBe = NumberToStringConverter.GetConverter("FR-be");

frBe.ConvertOrdinal(1);   // "premier"           ← exception
frBe.ConvertOrdinal(71);  // "septante et unième" ← Belgian 70 + word rule for "un"
frBe.ConvertOrdinal(80);  // "huitantième"        ← Belgian 80 + removeTrailing="e"
frBe.ConvertOrdinal(90);  // "nonantième"
NumberToStringConverter nl = NumberToStringConverter.GetConverter("NL");

nl.ConvertOrdinal(1);   // "eerste"           ← exception
nl.ConvertOrdinal(2);   // "tweede"           ← word rule
nl.ConvertOrdinal(8);   // "achtste"          ← suffix "ste"
nl.ConvertOrdinal(11);  // "elfde"            ← word rule (exception 11=elf)
nl.ConvertOrdinal(20);  // "twintigste"       ← suffix "ste"
nl.ConvertOrdinal(21);  // "eenentwintigste"  ← fused compound + suffix "ste"
nl.ConvertOrdinal(101); // "honderd eerste"   ← word rule for "een"
NumberToStringConverter eu = NumberToStringConverter.GetConverter("EU");

eu.ConvertOrdinal(1);    // "lehenengo"           ← irregular first
eu.ConvertOrdinal(2);    // "bigarren"            ← suffix "garren"
eu.ConvertOrdinal(10);   // "hamargarren"
eu.ConvertOrdinal(11);   // "hamaikagarren"       ← exception 11=hamaika + suffix
eu.ConvertOrdinal(21);   // "hogeita batgarren"   ← "bat" in compound gets suffix
NumberToStringConverter de = NumberToStringConverter.GetConverter("DE");

de.ConvertOrdinal(1);    // "erste"             ← irregular
de.ConvertOrdinal(3);    // "dritte"            ← irregular
de.ConvertOrdinal(7);    // "siebte"            ← irregular
de.ConvertOrdinal(2);    // "zweite"            ← word rule
de.ConvertOrdinal(20);   // "zwanzigste"        ← suffix "ste"
de.ConvertOrdinal(21);   // "einundzwanzigste"  ← fused compound + suffix
de.ConvertOrdinal(1000); // "tausendste"
de.ConvertOrdinal(1001); // "tausend erste"     ← word rule "ein" → "erste"
NumberToStringConverter es = NumberToStringConverter.GetConverter("ES");

es.ConvertOrdinal(1);                      // "primero"
es.ConvertOrdinal(10);                     // "décimo"
es.ConvertOrdinal(20);                     // "vigésimo"
es.ConvertOrdinal(1,  "gender=femenino");  // "primera"
es.ConvertOrdinal(20, "gender=femenino");  // "vigésima"
NumberToStringConverter it = NumberToStringConverter.GetConverter("IT");

it.ConvertOrdinal(1);                        // "primo"
it.ConvertOrdinal(11);                       // "undicesimo"
it.ConvertOrdinal(20);                       // "ventesimo"
it.ConvertOrdinal(1000);                     // "millesimo"
it.ConvertOrdinal(1,    "gender=femminile"); // "prima"
it.ConvertOrdinal(1000, "gender=femminile"); // "millesima"
NumberToStringConverter pt = NumberToStringConverter.GetConverter("PT");

pt.ConvertOrdinal(1);                          // "primeiro"
pt.ConvertOrdinal(11);                         // "décimo primeiro"   ← compound exception
pt.ConvertOrdinal(1000);                       // "milésimo"
pt.ConvertOrdinal(1,  "gender=feminino");      // "primeira"
pt.ConvertOrdinal(21, "gender=feminino");      // "vinte e primeira"  ← feminine compound
pt.ConvertOrdinal(22, "gender=feminino");      // "vinte e segunda"
NumberToStringConverter ca = NumberToStringConverter.GetConverter("CA");

ca.ConvertOrdinal(1);                      // "primer"          ← exception
ca.ConvertOrdinal(5);                      // "cinquè"          ← word rule
ca.ConvertOrdinal(20);                     // "vintè"           ← suffix "è" (trailing "a" stripped)
ca.ConvertOrdinal(1,  "gender=femení");    // "primera"
ca.ConvertOrdinal(21, "gender=femení");    // "vint-i-unena"    ← feminine + suffix "ena"
ca.ConvertOrdinal(22, "gender=femení");    // "vint-i-dosena"   ← word rule "dues" → "dosena"
NumberToStringConverter gl = NumberToStringConverter.GetConverter("GL");

gl.ConvertOrdinal(1);                      // "primeiro"
gl.ConvertOrdinal(12);                     // "duodécimo"        ← unique to Galician
gl.ConvertOrdinal(20);                     // "vixésimo"
gl.ConvertOrdinal(1,  "gender=feminino");  // "primeira"
gl.ConvertOrdinal(21, "gender=feminino");  // "vinte e primeira" ← "unha" → "primeira"
NumberToStringConverter he = NumberToStringConverter.GetConverter("HE");

he.ConvertOrdinal(1);                    // "ראשון"   ← masculine default
he.ConvertOrdinal(10);                   // "עשירי"
he.ConvertOrdinal(1, "gender=nekeva");   // "ראשונה"  ← feminine
he.ConvertOrdinal(3, "gender=nekeva");   // "שלישית"
he.ConvertOrdinal(20);                   // "עשרים"   ← above 10: cardinal fallback
// Prefix ordinals (ZH, JA, KO, EE)
NumberToStringConverter.GetConverter("ZH").ConvertOrdinal(1);   // "第一"
NumberToStringConverter.GetConverter("JA").ConvertOrdinal(3);   // "第三"
NumberToStringConverter.GetConverter("KO").ConvertOrdinal(2);   // "제이"
NumberToStringConverter.GetConverter("EE").ConvertOrdinal(1);   // "gbãtõ" ← irregular
NumberToStringConverter.GetConverter("EE").ConvertOrdinal(2);   // "etsõ eve"

SupportsOrdinals

INumberToStringConverter conv = NumberToStringConverter.GetConverter("DE");
if (conv.SupportsOrdinals)
    Console.WriteLine(conv.ConvertOrdinal(5));  // "fünfte"

SupportsOrdinals returns false for languages that have no ordinal configuration (ZU…) and for any INumberToStringConverter implementation that does not override the default.

Ordinal pipeline: word-level rules are matched against the raw cardinal text, before AdjustFunction and INumberToStringLanguageSpecifics.FinalizeWriting are applied. AdjustFunction (and FinalizeWriting) then run on the ordinal result. This means a converter with an uppercase AdjustFunction correctly produces "TWENTY-FIRST", not "TWENTY-ONEth".

Languages without ordinals: ZU (Zulu), RO (Romanian). Zulu ordinals require noun-class agreement and are not yet implemented. Romanian ordinals are not yet implemented. For languages that have ordinals, converter.SupportsOrdinals returns true.


Morphological variants

Many languages inflect numbers for gender or grammatical case. Variants are declared per language in the XML configuration as named dimensions with ordered values. The first declared value is the default — calling Convert without parameters automatically uses it.

French — grammatical gender

French has one variant dimension: gender (masculin / feminin).

NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");

// No parameter → masculine (first value = default)
fr.Convert(1);   // "un"
fr.Convert(21);  // "vingt et un"

// Explicit feminine
fr.Convert(1,  "gender=feminin");   // "une"
fr.Convert(21, "gender=feminin");   // "vingt et une"
fr.Convert(31, "gender=feminin");   // "trente et une"
fr.Convert(61, "gender=feminin");   // "soixante et une"

// Replacement applies to the LAST word only
// "million" is not replaced even in feminine
fr.Convert(1_000_000, "gender=feminin");    // "un million"
fr.Convert(1_000_021, "gender=feminin");    // "un million vingt et une"

Listing available variants for a language

NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");

foreach (var dimension in fr.VariantDimensions)
{
    Console.WriteLine($"{dimension.Name}: {string.Join(", ", dimension.Values)}");
    Console.WriteLine($"  default: {dimension.DefaultValue}");
}
// gender: masculin, feminin
//   default: masculin

Via the INumberToStringConverter interface:

INumberToStringConverter converter = NumberToStringConverter.GetConverter("FR");
bool supportsGender = converter.VariantDimensions
    .Any(d => d.Name.Equals("gender", StringComparison.OrdinalIgnoreCase));

Spanish — gender and hundreds

In Spanish, uno (1) and compound hundreds -cientos vary in gender.

NumberToStringConverter es = NumberToStringConverter.GetConverter("ES");

es.Convert(1);                      // "uno"
es.Convert(1,   "gender=femenino"); // "una"
es.Convert(200, "gender=femenino"); // "doscientas"
es.Convert(500, "gender=femenino"); // "quinientas"
es.Convert(900, "gender=femenino"); // "novecientas"

Limitation: fused compound forms without spaces (veintiuno, treintauno) are not converted — the LastWord rule requires a space or hyphen before uno. Fixing the buildStrings in the configuration ("treinta y *" instead of "treinta*") would solve this.

Portuguese — gender with spaces, units and hundreds

Portuguese uses spaces in all its compounds (vinte e um), making the LastWord rule effective for every form. um and dois vary, as do all hundreds (except 100 cem/cento).

NumberToStringConverter pt = NumberToStringConverter.GetConverter("PT");

pt.Convert(1,   "gender=feminino"); // "uma"
pt.Convert(2,   "gender=feminino"); // "duas"
pt.Convert(21,  "gender=feminino"); // "vinte e uma"
pt.Convert(22,  "gender=feminino"); // "vinte e duas"
pt.Convert(200, "gender=feminino"); // "duzentas"
pt.Convert(201, "gender=feminino"); // "duzentas e uma"
pt.Convert(202, "gender=feminino"); // "duzentas e duas"

// The multiplier before "mil" stays masculine (last word = "mil")
pt.Convert(2_000, "gender=feminino"); // "dois mil"  (limitation)

Italian — only uno varies

In Italian, hundreds (duecento, trecento…) are invariable in gender. Only uno → una changes.

NumberToStringConverter it = NumberToStringConverter.GetConverter("IT");

it.Convert(1, "gender=femminile"); // "una"
it.Convert(100); // "cento"         ← invariable
it.Convert(200); // "duecento"      ← invariable

Limitation: fused compounds (ventiuno, trentuno…) are not converted, for the same reason as Spanish.

Catalan — hyphens as word boundaries

Catalan uses hyphens in its compounds (vint-i-un, trenta-un…). A hyphen is a word boundary for LastWord, so the rule applies correctly to compound numbers. Only dos-cents (200) has a feminine form among hundreds.

NumberToStringConverter ca = NumberToStringConverter.GetConverter("CA");

ca.Convert(1,   "gender=femení"); // "una"
ca.Convert(2,   "gender=femení"); // "dues"
ca.Convert(21,  "gender=femení"); // "vint-i-una"   ← hyphen = word boundary ✓
ca.Convert(22,  "gender=femení"); // "vint-i-dues"
ca.Convert(31,  "gender=femení"); // "trenta-una"
ca.Convert(200, "gender=femení"); // "dues-centes"
ca.Convert(201, "gender=femení"); // "dues-centes una"

Galician — like Portuguese

Galician uses spaces (vinte e un) and follows logic similar to Portuguese. un → unha, dous → dúas, and only douscentos (200) has a feminine form.

NumberToStringConverter gl = NumberToStringConverter.GetConverter("GL");

gl.Convert(1,   "gender=feminino"); // "unha"
gl.Convert(2,   "gender=feminino"); // "dúas"
gl.Convert(21,  "gender=feminino"); // "vinte e unha"
gl.Convert(22,  "gender=feminino"); // "vinte e dúas"
gl.Convert(200, "gender=feminino"); // "douscentas"
gl.Convert(201, "gender=feminino"); // "douscentas unha"

Belgian/Swiss French — same gender, different words

FR-be and FR-ch use septante/huitante/nonante instead of soixante-dix/quatre-vingts/quatre-vingt-dix, but the gender rule is identical to FR: the gender dimension (masculin/féminin) is available.

NumberToStringConverter frBe = NumberToStringConverter.GetConverter("FR-be");

frBe.Convert(71);                   // "septante et un"
frBe.Convert(71, "gender=feminin"); // "septante et une"
frBe.Convert(81, "gender=feminin"); // "huitante et une"
frBe.Convert(91, "gender=feminin"); // "nonante et une"

// "un million" → last word = "million" → no replacement
frBe.Convert(1_000_000, "gender=feminin"); // "un million"

German — genus × kasus

In German, only one digit is declined: ein (1) takes different forms. Compounds like einundzwanzig (21) are invariable.

NumberToStringConverter de = NumberToStringConverter.GetConverter("DE");

// Default (masculine nominative) — GermanSpecifics: "ein" → "eins"
de.Convert(1);   // "eins"

// Gender and case variations
de.Convert(1, "genus=feminin");                       // "eine"
de.Convert(1, "kasus=akkusativ", "genus=maskulin");   // "einen"
de.Convert(1, "kasus=akkusativ", "genus=feminin");    // "eine"
de.Convert(1, "kasus=dativ",     "genus=maskulin");   // "einem"
de.Convert(1, "kasus=dativ",     "genus=neutrum");    // "einem"
de.Convert(1, "kasus=dativ",     "genus=feminin");    // "einer"
de.Convert(1, "kasus=genitiv",   "genus=maskulin");   // "eines"
de.Convert(1, "kasus=genitiv",   "genus=neutrum");    // "eines"
de.Convert(1, "kasus=genitiv",   "genus=feminin");    // "einer"

// Compounds are not declined
de.Convert(21, "genus=feminin");  // "einundzwanzig"  (unchanged)

// "eine Million": GermanSpecifics corrects "ein Million" → "eine Million" independently of variants
de.Convert(1_000_000);  // "eine Million"

Full inflection table for ein:

kasus \ genus maskulin feminin neutrum
Nominativ eins* eine eins*
Akkusativ einen eine eins*
Dativ einem einer einem
Genitiv eines einer eines

* GermanNumberToStringLanguageSpecifics converts the raw form ein to eins (counting form). For accusative/nominative neuter, the adjectival form ein (without -s) and the counting form eins are indistinguishable without syntactic context; the system returns eins in both cases.

Finnish — grammatical cases (sijamuoto)

Finnish has no grammatical gender but has 15 cases. Three cases are implemented: nominative (default), partitive (partitiivi) and genitive (genetiivi).

Implementation notes:

  • Compound tens (kaksikymmentä…) and compound hundreds (kaksisataa…) are already in a partitive-compatible form in the configuration: only units and standalone scale words need LastWord rules.
  • In the genitive, compound tens and hundreds change entirely (kaksikymmentä → kahdenkymmenen) via Anywhere.
  • seitsemän, kahdeksan, yhdeksän (7, 8, 9) are invariable in the genitive.
NumberToStringConverter fi = NumberToStringConverter.GetConverter("FI");

// Nominative (default)
fi.Convert(1);    // "yksi"
fi.Convert(21);   // "kaksikymmentä yksi"
fi.Convert(100);  // "sata"

// Partitive
fi.Convert(1,   "sijamuoto=partitiivi"); // "yhtä"
fi.Convert(2,   "sijamuoto=partitiivi"); // "kahta"
fi.Convert(5,   "sijamuoto=partitiivi"); // "viittä"
fi.Convert(10,  "sijamuoto=partitiivi"); // "kymmentä"
fi.Convert(11,  "sijamuoto=partitiivi"); // "yhtätoista"
fi.Convert(21,  "sijamuoto=partitiivi"); // "kaksikymmentä yhtä"
fi.Convert(100, "sijamuoto=partitiivi"); // "sataa"
fi.Convert(201, "sijamuoto=partitiivi"); // "kaksisataa yhtä"

// Genitive
fi.Convert(2,   "sijamuoto=genetiivi"); // "kahden"
fi.Convert(20,  "sijamuoto=genetiivi"); // "kahdenkymmenen"
fi.Convert(21,  "sijamuoto=genetiivi"); // "kahdenkymmenen yhden"
fi.Convert(200, "sijamuoto=genetiivi"); // "kahdensadan"
fi.Convert(221, "sijamuoto=genetiivi"); // "kahdensadan kahdenkymmenen yhden"
fi.Convert(11,  "sijamuoto=genetiivi"); // "yhdentoista"

Limitation: in a compound number like sata yksi (101), the word sata (hundred) is not converted to sadan in the genitive, because it is neither the last word nor alone in the text. An Anywhere "sata"→"sadan" rule would corrupt compound forms like kaksisataa. Similarly, yksi tuhat (1000) produces yksi tuhatta in the partitive.

Hebrew — gender paradox (zachar / nekeva)

In Hebrew, digits 3-10 exhibit a "gender paradox": the grammatically feminine form (-ה) is used with masculine nouns (zachar), and the form without ה is used with feminine nouns (nekeva).

The gender dimension has three values:

  • standalone (default): forms compatible with feminine nouns and abstract counting
  • zachar (masculine nouns): adds ה to 3-9, 2 → שניים, 10 → עשרה
  • nekeva (feminine nouns): only 1 changes (אחד → אחת)
NumberToStringConverter he = NumberToStringConverter.GetConverter("HE");

he.Convert(1);                    // "אחד"   (standalone / default)
he.Convert(1, "gender=nekeva");   // "אחת"
he.Convert(2, "gender=zachar");   // "שניים"
he.Convert(3, "gender=zachar");   // "שלושה"

Limitation: the multiplier before אלף (thousands) is not converted because "אלף" is the last word, not the unit.

Discovering available variants

NumberToStringConverter de = NumberToStringConverter.GetConverter("DE");

foreach (var dim in de.VariantDimensions)
    Console.WriteLine($"{dim.Name}: {string.Join(", ", dim.Values)}  (default: {dim.DefaultValue})");
// genus: maskulin, feminin, neutrum  (default: maskulin)
// kasus: nominativ, akkusativ, dativ, genitiv  (default: nominativ)

Architecture — multi-dimensional variants and cascade

The XML configuration declares each dimension, then replacement rules from least specific to most specific. The declaration order of rules at equal constraint levels matters: a rule transforms the text in sequence, and the next rule sees the result of the previous one.

<Variants>
  <Dimension name="genus"  values="maskulin,feminin,neutrum" />
  <Dimension name="kasus"  values="nominativ,akkusativ,dativ,genitiv" />

  
  <Variant genus="feminin">
    <Replacement oldValue="ein" newValue="eine" scope="LastWord" />
  </Variant>
  
  <Variant kasus="dativ">
    <Replacement oldValue="ein" newValue="einem" scope="LastWord" />
  </Variant>
  <Variant kasus="genitiv">
    <Replacement oldValue="ein" newValue="eines" scope="LastWord" />
  </Variant>

  
  <Variant kasus="akkusativ" genus="maskulin">
    <Replacement oldValue="ein" newValue="einen" scope="LastWord" />
  </Variant>
  
  <Variant kasus="dativ" genus="feminin">
    <Replacement oldValue="eine" newValue="einer" scope="LastWord" />
  </Variant>
  <Variant kasus="genitiv" genus="feminin">
    <Replacement oldValue="eine" newValue="einer" scope="LastWord" />
  </Variant>
</Variants>

Cascade rules: variants with fewer constraints are applied before those with more constraints. Within the same specificity level, the declaration order is preserved — allowing transformations to be composed.

LastWord scope: the replacement only applies if oldValue matches exactly the last word of the result (separated by a space or hyphen). This prevents modifying ein inside einundzwanzig or in ein million when the last word is million.

Unknown dimensions: if the caller passes a dimension not declared for a language, it is silently ignored — the result is the same as calling without any variant.

Languages with no morphological variants

The following languages have no declared variants, either because their numeral morphology is invariable in common contexts, or because the morphological distinction is not yet implemented.

Code Language Reason
EN English Numbers are invariable (no gender or case)
NL Dutch Numbers are invariable
KO Korean Numbers are invariable
ZH Chinese No inflection
JA Japanese No inflection
EU Basque No grammatical gender (language isolate)
HI Hindi Numbers are invariable in common usage
ZU Zulu Not yet implemented
EE Ewe Not yet implemented

For all these languages, VariantDimensions returns an empty list and any parameter passed to Convert() is silently ignored.


Currency

using Utils.NumberToString;

var euro = new CurrencyDefinition
{
    UnitSingular    = "euro",
    UnitPlural      = "euros",
    SubunitSingular = "centime",
    SubunitPlural   = "centimes",
    Connector       = "et",
};

NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");
fr.ConvertCurrency(1m,     euro);  // "un euro"
fr.ConvertCurrency(21.50m, euro);  // "vingt et un euros et cinquante centimes"
fr.ConvertCurrency(-5.01m, euro);  // "moins cinq euros et un centime"

var dollar = new CurrencyDefinition
{
    UnitSingular    = "dollar",
    UnitPlural      = "dollars",
    SubunitSingular = "cent",
    SubunitPlural   = "cents",
    Connector       = "and",
};

NumberToStringConverter en = NumberToStringConverter.GetConverter("EN");
en.ConvertCurrency(12.01m, dollar); // "twelve dollars and one cent"

SubunitDigits (default 2) controls the number of decimal places for subunits.


ForcedVariants — constituent-local grammatical constraints

A configured lexical constituent (a time unit, a currency unit/subunit, a fraction denominator term) may own an intrinsic grammatical constraint on the numeric fragment it governs. French "heure" is feminine, so 21 heures must render as vingt et une heures, not vingt et un heures — but the caller of Convert(TimeSpan) should not need to know that "heure" is feminine.

ForcedVariants let the constituent itself declare that constraint:

NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");

fr.Convert(21);                       // "vingt et un"        (ordinary cardinal: masculine default)
fr.Convert(TimeSpan.FromHours(21));   // "vingt et une heures" (the "hour" unit forces gender=feminin)

No caller variant is required — the built-in French configuration declares forceVariants="gender=feminin" on the hour/minute/second time units.

Precedence

For the numeric fragment governed by a constituent, the effective variant query is computed per dimension as:

language defaults  →  caller-supplied variants  →  constituent ForcedVariants

Each layer overrides the previous one only for the dimensions it mentions. A constituent forcing gender does not erase a caller-supplied case:

// "gender=masculin" from the caller does not override the hour unit's
// intrinsic gender=feminin — the constituent wins locally ("forced means forced").
fr.Convert(TimeSpan.FromHours(21), "gender=masculin"); // still "vingt et une heures"

Locality — no leakage

ForcedVariants apply only while building the fragment governed by the constituent that declares them. They never affect another fragment in the same phrase, another call, or the converter's global defaults:

fr.Convert(1);                        // "un"       — global default stays masculine
fr.Convert(new TimeSpan(1, 0, 0));    // "une heure" — local to the "hour" fragment only

Not just French

The same mechanism, unmodified, also covers Portuguese, Galician, and Catalan, whose hour noun is feminine — each declares forceVariants="gender=..." on its hour unit only:

NumberToStringConverter pt = NumberToStringConverter.GetConverter("PT");
pt.Convert(2);                       // "dois"
pt.Convert(new TimeSpan(2, 0, 0));   // "duas horas"      (the "hour" unit forces gender=feminino)

NumberToStringConverter ca = NumberToStringConverter.GetConverter("CA");
ca.Convert(21);                      // "vint-i-un"
ca.Convert(TimeSpan.FromHours(21));  // "vint-i-una hores" (the "hour" unit forces gender=femení)

Spanish (ES) also has time-unit support, but it needed one more dimension: masculine attributive numeral apocope ("uno"→"un", "veintiuno"→"veintiún") applies to compound counts (21, 31, …), not just count 1, which Count1Form alone cannot express. A form dimension (standalone/attributive), forced by the time units alongside gender, activates the correct existing gender-specific rule for every count uniformly — see the "Not just French" example turn into "not just gender" in NumberConvertionConfiguration.ES.xml and DONE-2026-08-25(1).md:

NumberToStringConverter es = NumberToStringConverter.GetConverter("ES");
es.Convert(21);                            // "veintiuno"        (standalone: unaffected)
es.Convert(new TimeSpan(0, 21, 0));        // "veintiún minutos" (minute forces form=attributive)
es.Convert(TimeSpan.FromHours(21));        // "veintiuna horas"  (hour forces gender=femenino,form=attributive)

Currency: independent unit and subunit

CurrencyDefinition.UnitForcedVariants and SubunitForcedVariants let a single currency force different grammar for its main unit and its subunit — one phrase, two independent local queries:

var euro = new CurrencyDefinition
{
    UnitSingular = "euro", UnitPlural = "euros",
    SubunitSingular = "centime", SubunitPlural = "centimes",
    // Masculine is already the FR default: no forcing needed.
};
var livre = new CurrencyDefinition
{
    UnitSingular = "livre", UnitPlural = "livres",
    SubunitSingular = "sou", SubunitPlural = "sous",
    UnitForcedVariants = ForcedVariantSet.Create(("gender", "feminin")),
};

fr.ConvertCurrency(21m, euro);   // "vingt et un euros"
fr.ConvertCurrency(21m, livre);  // "vingt et une livres"

Configuring ForcedVariants

  • XML: forceVariants="dimension=value" or forceVariants="dimension=value,dimension2=value2" on <Unit> (inside <TimeUnits>) or <Fraction>, reusing the same dimension=value vocabulary as caller-supplied variants.
  • Programmatic: NumberToStringConverterOptions.TimeUnitForcedVariants / FractionForcedVariants (keyed like TimeUnits/Fractions), or CurrencyDefinition.UnitForcedVariants/SubunitForcedVariants — all typed ForcedVariantSet, built via ForcedVariantSet.Create(("dimension", "value"), ...).

A dimension may be given by its canonical name or by its declared localName alias (e.g. French genre for gender) — both are canonicalized to the dimension's canonical name before the forced set is used, so genre=feminin and gender=feminin behave identically. Forcing the same dimension twice through a mix of its canonical name and an alias (e.g. gender=feminin,genre=masculin) is rejected as a duplicate, exactly like forcing it twice through the canonical name alone.

Unknown dimensions/values, malformed syntax, and duplicate dimensions are rejected deterministically (NumberToStringConfigurationException) — for time units and fraction terms at converter construction time, and for CurrencyDefinition at the start of ConvertCurrency, before any fragment is rendered.


Lexical form selection — ILexicalFormSelector

ForcedVariants constrains the grammar of the NUMBER a constituent governs. A separate, complementary concern is which FORM OF THE UNIT WORD itself applies for a given count — the current Singular/Plural/Count1Form model assumes a binary choice, which is not enough for every language (e.g. Russian "час"/"часа"/"часов" for 1 / 2–4 / 5+). ILexicalFormSelector makes that choice extensible:

public interface ILexicalFormSelector
{
    string SelectForm(LexicalFormContext context);
}

A selector returns a form key ("singular", "plural", "one", "few", …) — never the localized word. The key-to-word mapping is owned by configuration (<Forms><Form key="..." value="..."/></Forms> in XML, or NumberToStringConverterOptions.TimeUnitForms programmatically), not by the selector. Every unit that configures no selector uses DefaultLexicalFormSelector (AbsoluteValue == 1 → "singular", else "plural") — exactly today's behavior, so existing configurations are unaffected.

var options = new NumberToStringConverterOptions(NumberToStringConverter.GetConverter("EN"))
{
    TimeUnitForms = new Dictionary<string, LexicalFormSet>
    {
        ["hour"] = LexicalFormSet.Create(("one", "час"), ("few", "часа"), ("many", "часов")),
    },
    TimeUnitFormSelectors = new Dictionary<string, ILexicalFormSelector>
    {
        ["hour"] = new MyCountBucketSelector(), // your ILexicalFormSelector implementation
    },
};

XML configuration resolves a formSelector="..." type name — while loading configuration, never on the conversion hot path — through a single resolution registry backed by the repository-standard Utils.Collections.CachedLoader: the built-in "default" alias and any name passed to RegisterLexicalFormSelector are preloaded directly into that registry, so they never touch reflection; any other type name is resolved by reflection on first use and the resulting activation strategy is cached per distinct type name, so later uses of the same name skip reflection. Register a name in advance to skip reflection entirely:

NumberToStringConverter.RegisterLexicalFormSelector("my-company:count-bucket", new MyCountBucketSelector());
<Unit name="hour" singular="hour" plural="hours" formSelector="my-company:count-bucket">
  <Forms>
    <Form key="few" value="..." />
  </Forms>
</Unit>

A selector type may optionally declare a constructor accepting a LexicalFormSelectorConfiguration (type name, language identifier, and optional selector-specific Configuration) instead of a parameterless one; implementations must be stateless/thread-safe after construction, since a single instance is shared and may be called concurrently. A form key the selector returns but the unit doesn't configure is a deterministic NumberToStringConfigurationException ("UNTS007"), not a silent fallback.

Selector-specific XML configuration

A unit that needs to pass its own configuration to a custom selector (not just a type name) uses the structured <LexicalFormSelector> element instead of the plain formSelector attribute:

<Unit name="hour" singular="hour" plural="hours">
  <LexicalFormSelector type="MyCompany.CountBucketSelector, MyCompany.Assembly">
    <Configuration threshold="5" />
  </LexicalFormSelector>
  <Forms>
    <Form key="few" value="часа" />
  </Forms>
</Unit>

The <Configuration> subtree's shape is owned entirely by the selector — the core library never interprets its content, only hands it to the selector's LexicalFormSelectorConfiguration.Configuration (an XElement) as-is. Units that need no configuration keep using the concise formSelector="..." attribute unchanged; it is ignored when <LexicalFormSelector> is present.

Reflection is cached per type name, not per configured instance: resolving "MyCompany.CountBucketSelector, ..." for two units with two different <Configuration> subtrees performs the expensive type/constructor lookup once (cached as a reusable Func<LexicalFormSelectorConfiguration, ILexicalFormSelector> activation strategy, never as a shared instance), then re-invokes that cached activator with each unit's own configuration — so the same selector implementation can be reused with different settings across units without paying reflection cost twice, and without one unit's configuration leaking into another's.

This activator cache is a plain CachedLoader<string, Func<...>> over a ConcurrentDictionary, not a custom locking scheme: its check-then-load sequence is not atomic, so under a race, first-time resolution of the same not-yet-cached type name may run the reflection lookup more than once. Each run is deterministic and side-effect-free, so this is harmless — only one resulting activator ends up cached, and every resolution still returns a correctly-configured selector — but it means the guarantee is "the loader converges to one cached activator per type name," not "the loader runs at most once, ever."

Programmatically, an already-constructed ILexicalFormSelector instance never triggers reflection — reflection only ever resolves configured type names (XML formSelector/<LexicalFormSelector type="...">, or a type name passed to RegisterLexicalFormSelector), never instances supplied via NumberToStringConverterOptions.TimeUnitFormSelectors or an instance passed to RegisterLexicalFormSelector directly. A third RegisterLexicalFormSelector overload accepts Func<LexicalFormSelectorConfiguration, ILexicalFormSelector> so a registered name can still honor each unit's own configuration:

NumberToStringConverter.RegisterLexicalFormSelector(
    "my-company:count-bucket",
    config => new MyCountBucketSelector(config.Configuration));

TimeUnitForms / TimeUnitFormSelectors — effective, not override-only

NumberToStringConverter.TimeUnitForms and .TimeUnitFormSelectors report the effective state for every key in TimeUnits — including units that configured no override at all, which show a synthesized {"singular": ..., "plural": ...} form set and DefaultLexicalFormSelector respectively. A unit with an explicit override shows its singular/plural merged with the override, not the override alone. This mirrors what FormatTimeUnit actually uses at conversion time, so inspecting these properties tells you exactly what a given unit will render.

Spanish does not use this mechanism — its "hora"/"minuto"/"segundo" only ever need ordinary singular/plural word forms. Its attributive apocope (21 minutos → veintiún minutos, not veintiuno minutos) is a ForcedVariants numeral-grammar concern instead, solved with a form dimension (standalone/attributive) — see the built-in NumberConvertionConfiguration.ES.xml. ILexicalFormSelector exists for the UNIT WORD; keep that distinction in mind before reaching for it.


Customisation via NumberToStringConverterOptions

Clone and modify an existing converter:

var options = new NumberToStringConverterOptions(NumberToStringConverter.GetConverter("EN"))
{
    AdjustFunction = text => text.ToUpperInvariant(),
    MaxNumber      = new BigInteger(999_999_999),
};
var converter = new NumberToStringConverter(options);

converter.Convert(42);            // "FORTY-TWO"
converter.Convert(1_000_000_000); // throws ArgumentOutOfRangeException

Registering additional XML configurations

// Load one or more XML configurations at startup
NumberToStringConverter.RegisterConfigurations([myXmlConfig]);

// Duplicate cultures are silently ignored (first registration wins)
NumberToStringConverter.RegisterConfigurations([xmlA, xmlA]); // does not throw

INumberToStringLanguageSpecifics

Post-processing hook applied after variants, as the last step of the pipeline. Useful when a grammatical rule requires context that cannot be expressed by simple replacements.

public class UpperCaseSpecifics : INumberToStringLanguageSpecifics
{
    public string FinalizeWriting(string languageIdentifier, string text)
        => text.ToUpperInvariant();
}

Factory registration

Registering a named instance avoids reflection-based lookup at XML deserialization time:

NumberToStringConverter.RegisterLanguageSpecifics(
    nameof(MyLanguageSpecifics),
    new MyLanguageSpecifics());

The content of the <LanguageSpecifics> node in the XML must match the full or short name passed here.

IOrdinalLanguageSpecifics

When the XML pipeline is insufficient (e.g. Semitic root-pattern morphology), implement IOrdinalLanguageSpecifics alongside INumberToStringLanguageSpecifics. TryConvertOrdinal is called first; returning false falls back to the XML pipeline.

public class MyOrdinalSpecifics : INumberToStringLanguageSpecifics, IOrdinalLanguageSpecifics
{
    public string FinalizeWriting(string lang, string text) => text;

    public bool TryConvertOrdinal(
        int number,
        IReadOnlyDictionary<string, string> variants,
        out string? result)
    {
        if (number == 0) { result = null; return false; }
        result = $"ordinal_{number}";
        return true;
    }
}

GermanNumberToStringLanguageSpecifics

Provided in the package. Corrects "ein" → "eins" (standalone number) and "ein Million" → "eine Million":

NumberToStringConverter de = NumberToStringConverter.GetConverter("DE");

de.Convert(1);          // "eins"         ← standalone ein → eins
de.Convert(21);         // "einundzwanzig"
de.Convert(1_000_000);  // "eine Million" ← ein + feminine noun
de.Convert(2_000_000);  // "zwei Millionen"

Significant-digits precision

Round a number to N most significant digits before converting, using standard rounding (≥ 5 rounds up):

NumberToStringConverter fr = NumberToStringConverter.GetConverter("FR");

fr.Convert(123456789);        // "cent vingt trois millions quatre cent cinquante six mille sept cent quatre-vingt-neuf"
fr.Convert(123456789, 3);     // "cent vingt trois millions"         (→ 123 000 000)
fr.Convert(123456789, 2);     // "cent vingt millions"               (→ 120 000 000)
fr.Convert(123456789, 1);     // "cent millions"                     (→ 100 000 000)

The rounding is done by MathEx.RoundToSignificantDigits (from omy.Utils.Mathematics) and then delegates to the normal Convert pipeline, so variants work as expected:

fr.Convert(123456789, 3, "gender=feminin"); // "cent vingt trois millions" (no gender change at this scale)

Conversion pipeline

number
  → ConvertRaw:
      for each group (millions, thousands, units, …):
          ConvertGroup                    (digit text for this group)
          Trigger group(N)                (optional: replacements on digit text)
          append scale name
          Replacements with onScale=N     (per-group rules, filtered by onValue)
          Trigger groupWithScale(N)       (optional: replacements on digit+scale text)
          push to stack
      assemble all groups
      Replacements without onScale        (global rules, filtered by onValue)
  → AdjustFunction      (optional user-supplied transformation)
  → ApplyVariantRules   (morphological replacements, least to most specific)
  → Trigger end         (optional: replacements on fully assembled text)
  → FinalizeWriting     (INumberToStringLanguageSpecifics)
  → sign wrapping       (Minus template if negative)

Ordinal pipeline (via ConvertOrdinal):

number
  → OrdinalExceptions      (integer-level early exit, e.g. 1 → "premier")
  → ConvertRaw + Triggers group/groupWithScale (same as cardinal)
  → ApplyVariantRules      (default variant values)
  → ApplyOrdinalTransform  (word rules + suffix on last word)
  → AdjustFunction         (user transform + FinalizeWriting)
  → Trigger end
  → sign wrapping

Applying ordinal rules before AdjustFunction ensures that word-level rules always match the raw cardinal text, regardless of any uppercase transformation or language-specific finalizer applied later.

Variants are applied before FinalizeWriting. This ensures that for German, a variant can act on the raw form "ein" before GermanNumberToStringLanguageSpecifics converts it to "eins".


XML Configuration

Language configurations are XML files whose structure is described by NumberConvertionConfiguration.xsd (namespace Utils/NumberConvertionConfiguration.xsd).

General structure

<?xml version="1.0" encoding="utf-8" ?>
<Numbers xmlns="Utils/NumberConvertionConfiguration.xsd">
    <Language groupSize="3" separator=" " groupSeparator=""
              zero="zéro" minus="moins *"
              decimalSeparator="virgule" fractionSeparator="sur">

        <Culture>FR</Culture>      
        <Culture>FR-fr</Culture>   

        <Groups>…</Groups>
        <NumberScale>…</NumberScale>
        <Replacements>…</Replacements>         
        <Exceptions>…</Exceptions>             
        <LanguageSpecifics>…</LanguageSpecifics> 
        <Fractions>…</Fractions>               
        <Ordinals suffix="…">…</Ordinals>      
        <Variants>…</Variants>                 
        <Trigger executeAt="…">…</Trigger>     

    </Language>
</Numbers>

A single file may contain multiple <Language> elements. Multiple <Culture> elements on the same language register the same converter under several codes.

<Language> attributes

Attribute Required Description
groupSize ✓ Number of digits per group (always 3 for thousands).
separator ✓ Word separator within a group (usually " ").
groupSeparator ✓ Text between groups (e.g. "," in English, "" in French).
zero ✓ Text for the value 0.
minus ✓ Template for negatives; * is replaced by the absolute value.
decimalSeparator Word between the integer and decimal parts (e.g. "point", "virgule").
fractionSeparator Connector for fractions (e.g. "sur", "over").
maxNumber Maximum accepted value; beyond this, ArgumentOutOfRangeException is thrown.
baseOn Culture code of a base language to inherit from. All settings are inherited and can be selectively overridden. Chains (A → B → C) are supported; the base must appear earlier in the same file or in a previously loaded file. An empty element (e.g. <Replacements />) explicitly overrides the base with an empty list.
groupConnector / groupConnectorThreshold Word inserted between the last two groups when the lowest group's value is below the threshold (e.g. English "one thousand and one" — groupConnector="and" groupConnectorThreshold="100").
intraGroupConnector / intraGroupConnectorThreshold Word inserted between the hundreds digit and the remainder within a group of 3, when hundreds are present and the remainder is below the threshold (e.g. Vietnamese 101 → "một trăm linh một" — intraGroupConnector="linh" intraGroupConnectorThreshold="10").
scaleConnector / scaleConnectorThreshold Word inserted between a group's text and its scale name (thousand/million/…) when the group's value is at or above the threshold (e.g. Romanian 20 000 → "douăzeci de mii" — scaleConnector="de" scaleConnectorThreshold="20").

baseOn — language inheritance

baseOn lets a <Language> element inherit all settings from a base language and override only the differences. The base must appear earlier in the same file or in a previously loaded file. Inheritance chains (A → B → C) are fully supported.

Definitions loaded by ReadConfiguration can be reused by later ReadConfiguration calls, but they are not globally registered converters and are not visible to RegisterConfigurations.


<Language groupSize="3" …>
    <Culture>DE</Culture>
    <Replacements>
        <Replacement oldValue="ein tausend" newValue="tausend" />
    </Replacements>
    …
</Language>


<Language baseOn="DE">
    <Culture>de-CH</Culture>
    <Culture>de-LI</Culture>
    
    <Replacements />
</Language>

Merge rules:

  • Scalar attributes (groupSize, separator, zero, …): child wins; absent child attributes inherit from the base.
  • Collection elements (Groups, NumberScale, Replacements, Exceptions, Fractions, Variants): if declared in the child the entire collection replaces the base. Omitted collections are inherited. An empty element (e.g. <Replacements />) explicitly overrides with an empty list.
  • Ordinals: OrdinalExceptions and OrdinalRules are merged element-by-element (child wins on key conflicts). suffix, prefix, and OrdinalVariants fall back to the base when absent in the child.

<Groups> — digit tables

Each <Group level="N"> declares how digits 0–9 are written at position N in a group: level="1" = units, level="2" = tens, level="3" = hundreds.

Each <Digit digit="N" string="…" buildString="…"/>:

  • string — text when this digit is alone in its position.
  • buildString — template with * replaced by the lower sub-group.
<Groups>
    <Group level="1">
        <Digit digit="0" string="" />
        <Digit digit="1" string="et un" />
        <Digit digit="2" string="deux" />
        
    </Group>
    <Group level="2">
        <Digit digit="0" string="" buildString="*" />
        <Digit digit="2" string="vingt" buildString="vingt *" />
        
        
    </Group>
    <Group level="3">
        <Digit digit="1" string="cent" buildString="cent *" />
        
    </Group>
</Groups>

<NumberScale> — names of large powers

<NumberScale firstLetterUpperCase="false" voidGroup="ni" groupSeparator="lli" startIndex="0">

    
    <StaticNames>
        <Scale value="0" string=""/>        
        <Scale value="1" string="mille"/>   
        
    </StaticNames>

    
    <Suffixes>
        <Suffix>on(s)</Suffix>    
        <Suffix>ard(s)</Suffix>   
    </Suffixes>

    
    <Scale0Prefixes>…</Scale0Prefixes>
    <UnitsPrefixes>…</UnitsPrefixes>
    <TensPrefixes>…</TensPrefixes>
    <HundredsPrefixes>…</HundredsPrefixes>

</NumberScale>

firstLetterUpperCase="true" capitalises generated scale names (useful for German: "Million", "Milliarde"). The "(s)" string in names is a plural marker.


<Replacements> — substitutions

Rules fire either per-group (with onScale) or on the final assembled string (without onScale).

<Replacements>
    
    
    <Replacement oldValue="un mille" newValue="mille" />

    
    <Replacement oldValue="vingt et " newValue="vingt-" scope="Anywhere" />

    
    <Replacement oldValue="un" newValue="une" scope="LastWord" />

    
    
    
    <Replacement oldValue="ein tausend" newValue="tausend" onScale="1" onValue="1" />
</Replacements>
scope values
Scope Behaviour
Standalone (default) Replaces if the entire text equals oldValue.
Anywhere Replaces all substring occurrences.
LastWord Replaces oldValue only if it matches the last word (preceded by a space, hyphen, or start of string).
StartsWith Replaces if the text starts with oldValue.
EndsWith Replaces if the text ends with oldValue.
onScale — per-group firing

onScale restricts the rule to the per-group pass for one or more scale groups. It accepts the same comma-separated range syntax as onValue:

Expression Matches
1 Only the thousands group
1..3 Thousands, millions, and billions
2.. Millions and above
1,3 Thousands and billions only

The rule then sees "digit-text + separator + scale-name" (e.g. "ein tausend") rather than the fully assembled string. Without onScale, the rule fires on the final assembled string.

onValue — numeric value filter

onValue restricts the rule to specific numeric values. Syntax: comma-separated segments.

Segment Matches
1 Exactly 1
1..3 1, 2, or 3 (inclusive range)
..5 Any value ≤ 5
5.. Any value ≥ 5
1,5..10 1, or 5 through 10

With onScale: the value is the per-group digit value (0–999 for 3-digit groups).
Without onScale: the value is the full absolute number, applied in the final pass.



<Replacement oldValue="ein tausend" newValue="tausend" onScale="1" onValue="1" />


<Replacement oldValue="ein" newValue="ett" onValue="1..10" />

<Exceptions> — irregular forms

Checked with absolute priority before the grouping algorithm. Useful for numbers whose form is completely irregular.

<Exceptions>
    <Number value="1"  string="un" />      
    <Number value="11" string="onze" />
    <Number value="71" string="soixante onze" />
    
</Exceptions>

<LanguageSpecifics> — finalisation hook

The full or short type name of an INumberToStringLanguageSpecifics implementation called as the last step of the pipeline. Can be pre-registered via RegisterLanguageSpecifics() to avoid reflection-based lookup.

<LanguageSpecifics>GermanNumberToStringLanguageSpecifics</LanguageSpecifics>

<Fractions> — decimal denominator suffixes

Allow the decimal part of a number to be expressed with a named denominator. "(s)" is a plural marker.

<Fractions>
    <Fraction digits="1" string="dixième(s)" />    
    <Fraction digits="2" string="centième(s)" />   
    <Fraction digits="3" string="millième(s)" />
</Fractions>

<Ordinals> — ordinal conversion

Required to enable ConvertOrdinal().

Resolution order (highest to lowest priority):

  1. Active variant exceptions — from <OrdinalVariants>, most-specific constraint first.
  2. Base <OrdinalException> — whole-number match.
  3. Active variant word rules — from <OrdinalVariants>, most-specific first.
  4. Base <Ordinal> word rule — last-word match.
  5. Default suffix (± removeTrailing strip).
<Ordinals suffix="ième" removeTrailing="e">

    
    <OrdinalException value="1" string="premier" />

    
    <Ordinal from="un"   to="unième" />
    <Ordinal from="cinq" to="cinquième" />
    <Ordinal from="neuf" to="neuvième" />

    
    
    

</Ordinals>
Attribute Description
suffix Suffix added to the last word when no word rule matches.
removeTrailing String to strip from the end of the last word before adding suffix (only when the word actually ends with this value).
prefix String prepended to the entire ordinal result (e.g. "第" for Chinese, "etsõ " for Ewe). May be combined with exceptions; suffix and word rules are ignored when prefix is set.

<Ordinals prefix="第">
    
</Ordinals>


<Ordinals prefix="etsõ ">
    <OrdinalException value="1" string="gbãtõ" />
    
</Ordinals>
Variant-specific ordinal rules — <OrdinalVariants>

<OrdinalVariants> lets a single ordinal configuration produce gender- or case-inflected forms. Each <Variant> block targets one dimension value via type= (dimension name) and variant= (value). The most-specific matching variant (most constraints) wins.

<Ordinals suffix="ième" removeTrailing="e">
    <OrdinalException value="1" string="premier" />
    <Ordinal from="cinq" to="cinquième" />

    <OrdinalVariants>
        <Variant type="gender" variant="feminin">
            <OrdinalException value="1" string="première" />
            
            
        </Variant>
    </OrdinalVariants>
</Ordinals>

<Variant> attributes inside <OrdinalVariants>:

Attribute Description
type Dimension name (e.g. "gender", "case") or its localName alias.
variant Single dimension value this block applies to.
values Comma-separated list of values — shorthand for declaring several identical blocks.
suffix Suffix override for this variant (replaces the <Ordinals> base suffix).
removeTrailing removeTrailing override for this variant.

Nested <Variant> children inherit the parent constraint and add their own (cascade):

<OrdinalVariants>
    <Variant type="gender" variant="feminin">
        <OrdinalException value="1" string="prima" />
        <Variant type="case" variant="accusative">
            <OrdinalException value="1" string="primam" />  
        </Variant>
    </Variant>
</OrdinalVariants>
Compact multi-gender ordinals with forms=

When all dimension values share the same exception or word rule structure, write both forms inline instead of duplicating them in <OrdinalVariants>. The <Variant> child uses the forms= attribute with one form per dimension value in declaration order:

<Variants>
    <Dimension name="gender" values="masculin,feminin" />
    
</Variants>
<Ordinals suffix="ième" removeTrailing="e">
    
    <OrdinalException value="1">
        <Variant type="gender" forms="premier,première" />
    </OrdinalException>
    
    <Ordinal from="un" to="unième" />
</Ordinals>

The same syntax applies to word-level rules:

<Ordinals>
    <Ordinal from="uno">
        <Variant type="gender" forms="primero,primera" />
    </Ordinal>
</Ordinals>

Default form: when string= is absent from <OrdinalException> (or to= from <Ordinal>), the first form in forms= order is automatically registered as the no-variant default. ConvertOrdinal(1) (no gender) returns "premier" without any extra configuration.

Empty entries: an empty slot (e.g. forms=",première") skips the corresponding dimension value — no rule is generated for that position.

When to use <OrdinalVariants> instead: use it when a variant requires a suffix override, or when some variants need word-form mappings that do not align position-for-position with the dimension values (e.g. feminine-only cardinals "una"/"duas" that have no masculine counterpart among the ordinal word rules).


<Variants> — morphological variants

Declares the variation dimensions and associated replacement rules. Activated by calls to Convert(number, "dimension=value", …).

<Variants>

    
    <Dimension name="gender" localName="genus" values="maskulin,feminin,neutrum" />
    <Dimension name="case"   localName="kasus" values="nominativ,akkusativ,dativ,genitiv" />

    
    <Variant type="gender" variant="feminin">
        
        <Replacement oldValue="ein" newValue="eine" scope="LastWord" />
    </Variant>

    
    <Variant type="case" values="dativ,genitiv">
        <Replacement oldValue="eine" newValue="einer" scope="LastWord" />
    </Variant>

    
    <Variant type="gender" variant="feminin">
        <Variant type="case" variant="dativ">
            <Replacement oldValue="eine" newValue="einer" scope="LastWord" />
        </Variant>
    </Variant>

</Variants>

Cascade rules: variants are applied in ascending order of constraint count. A 2-constraint variant can therefore override the result of a 1-constraint variant. Unrecognised dimension names and unknown values are silently ignored.

<Dimension> attributes:

Attribute Required Description
name ✓ Canonical English identifier used in API calls ("gender", "case").
localName Optional language-specific alias (e.g. "genus", "sijamuoto"). Normalised to name internally.
values ✓ Comma-separated ordered list of valid values. The first value is the default.

<Variant> attributes inside <Variants>:

Attribute Description
type Dimension name (canonical or localName).
variant Single value that must be active. Mutually exclusive with values.
values Comma-separated list of values — shorthand for several identical blocks.

<Replacement> elements inside <Variant> support child <Variant> nodes with forms= for multi-dimensional replacements (see FormVariantType in the XSD):


<Replacement oldValue="ein" scope="LastWord">
    <Variant type="gender" variant="maskulin">
        <Variant type="case" forms="eins,einen,einem,eines" />
    </Variant>
</Replacement>

<YearFormat> — year conversion

Optional. When present, ConvertYear(int) uses a split-at-hundreds algorithm for year values within the declared <SplitRange> elements. Years outside all ranges fall back to Convert(year).

<YearFormat hundredWord="hundred" zeroConnector="oh">
    
    <SplitRange from="1100" to="1999" />
    
    <SplitRange from="2010" to="2099" />
</YearFormat>
Attribute Description
hundredWord Word appended when the year is a round century (e.g. "hundred" → "nineteen hundred").
zeroConnector Connector inserted before single-digit remainders (e.g. "oh" → "twenty oh five").
beforeChristSuffix Suffix appended to negative years instead of the minus template (e.g. "BC" → ConvertYear(-44) → "forty-four BC" instead of "minus forty-four").

<SplitRange from="N" to="M" /> declares an inclusive range [N, M] of year values that use the split algorithm. Multiple ranges may be declared; ranges outside the list fall back to Convert(year).


<Trigger> — pipeline hooks

<Trigger> elements apply text replacements at a specific moment in the conversion pipeline, optionally conditioned on active morphological variant values.

Execute positions — executeAt
Value When it fires Sees
"group" After ConvertGroup for each digit group Digit text only (no scale name yet)
"group(N)" Same, but only for group N (0 = units, 1 = thousands, 2 = millions, …) Digit text
"group(N,M,…)" Same, restricted to the listed group indices Digit text
"groupWithScale" After per-group Replacements, before pushing Digit+scale text
"groupWithScale(N)" Same, restricted to group N Digit+scale text
"end" After full assembly, AdjustFunction, and ApplyVariantRules Final assembled text

Warning: group and groupWithScale triggers also fire during ConvertOrdinal. If the trigger modifies a word that an ordinal word-rule targets (e.g. it replaces "ein" with something else), the ordinal transform may not match. Use "end" for post-ordinal corrections.

<Replace> — replacement rule

Each trigger contains one or more <Replace> elements. They are applied in declaration order, independently of each other — each selects exactly one form and applies it once.


<Trigger executeAt="end">
    <Replace from="et " to="&amp; " />
</Trigger>


<Trigger executeAt="group(0)">
    <Replace from="^one$" to="uno" regex="true" />
</Trigger>
Variant-conditioned replacements

When a <Replace> has <Variant> children, the most specific matching form is selected (same best-match algorithm as ordinal variants). The to= attribute becomes the unconditional default used when no form matches the active variant query.

Positional forms — one form per dimension value in declaration order:

<Trigger executeAt="end">
    
    <Replace from="ein$" regex="true" to="eins">
        <Variant type="genus" forms="eins,eine,eins" />
    </Replace>
</Trigger>

Single-value shorthand with value= — overrides exactly one dimension value:

<Trigger executeAt="end">
    
    <Replace from="ein$" regex="true" to="eins">
        <Variant type="genus" variant="feminin" value="eine" />
    </Replace>
</Trigger>

No default — when to= is absent and no variant matches, the replacement is skipped entirely (the regex is never evaluated):

<Trigger executeAt="group(0)">
    
    <Replace from="uno" regex="false">
        <Variant type="gender" variant="feminin" value="una" />
    </Replace>
</Trigger>
<Replace> attributes
Attribute Required Description
from ✓ Text or regex pattern to match.
to Explicit unconditional fallback. May contain backreferences ($1, ${name}) when regex="true". When absent, an unmatched query performs no replacement.
regex "true" to treat from as a .NET regular expression. Default: "false" (literal match).

The <Variant> children use the same FormVariantType syntax as <Replacement>, <Ordinal>, and <OrdinalException>:

Attribute Description
type Dimension name (canonical or localName).
variant Single value — marks this node as a constraint leaf. Used with value= or nested children.
forms Positional comma-separated forms, one per dimension value in declaration order. Leaf node syntax.
value Single output form for the specific variant named by variant=. Shorthand for single-value overrides.

  • omy.Utils — contains NumberToStringConverter, NumberToStringConverterOptions, and all built-in culture XML configurations.
  • omy.Utils.Mathematics — provides MathEx.RoundToSignificantDigits used by the significant-digits precision overload.

Versioned API documentation

Configuration validation and runtime contracts

Configuration completeness is validated only after all baseOn inheritance has been resolved. Diagnostics identify the culture and logical configuration path. Languages without a large-number scale must declare maxNumber below 10^groupSize; finite scales likewise require a bound that can be named by the configured scale tables.

RegisterConfigurations rejects normalized culture collisions by default. Use the overload taking DuplicateCulturePolicy to keep an existing converter or atomically replace it. Runtime variants use strict dimension=value syntax and reject unknown dimensions, values, and duplicates.

Language-specific finalization is applied to the complete public conversion result. Ordinal and multiplicative bodies, including configured exceptions, pass through adjustment, final triggers, and the language finalizer before their type prefix and sign are applied.

Deterministic rule precedence

Variant constraints are normalized to canonical dimension names and compared case-insensitively. Specificity is the number of canonical dimensions constrained by a rule. Priority is any signed 32-bit integer and defaults to 0; specificity is always considered before priority.

Unique ordinal and trigger-form selection uses specificity descending, then priority descending. An equal-ranked pair whose constraints do not contradict each other is rejected because both can match the same query. For example, gender=female and number=plural intersect and need different priorities, while gender=female and gender=male are mutually exclusive.

Cumulative VariantRule transformations run by specificity ascending and then priority ascending, so more specific transformations refine general ones and a higher priority runs later at equal specificity. Replacement order inside one rule remains an intentional sequence.

<OrdinalVariants>
    <Variant type="gender" variant="female" priority="100" suffix="th" />
</OrdinalVariants>

The priority attribute is optional and accepts the full xs:int range. During baseOn, inherited rules retain their priorities and child rules remain distinct. The merged configuration is rejected if inheritance creates an unresolved equal-rank intersection. Declaration order is never a tie-breaker in 2.0.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0-rc.1 244 8/28/2026