omy.Utils.NumberToString
2.0.0-rc.1
dotnet add package omy.Utils.NumberToString --version 2.0.0-rc.1
NuGet\Install-Package omy.Utils.NumberToString -Version 2.0.0-rc.1
<PackageReference Include="omy.Utils.NumberToString" Version="2.0.0-rc.1" />
<PackageVersion Include="omy.Utils.NumberToString" Version="2.0.0-rc.1" />
<PackageReference Include="omy.Utils.NumberToString" />
paket add omy.Utils.NumberToString --version 2.0.0-rc.1
#r "nuget: omy.Utils.NumberToString, 2.0.0-rc.1"
#:package omy.Utils.NumberToString@2.0.0-rc.1
#addin nuget:?package=omy.Utils.NumberToString&version=2.0.0-rc.1&prerelease
#tool nuget:?package=omy.Utils.NumberToString&version=2.0.0-rc.1&prerelease
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
AdjustFunctionandINumberToStringLanguageSpecifics.FinalizeWritingare applied.AdjustFunction(andFinalizeWriting) then run on the ordinal result. This means a converter with an uppercaseAdjustFunctioncorrectly 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.SupportsOrdinalsreturnstrue.
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 — theLastWordrule requires a space or hyphen beforeuno. Fixing thebuildStringsin 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 needLastWordrules. - In the genitive, compound tens and hundreds change entirely
(
kaksikymmentä→kahdenkymmenen) viaAnywhere. 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 wordsata(hundred) is not converted tosadanin the genitive, because it is neither the last word nor alone in the text. AnAnywhere "sata"→"sadan"rule would corrupt compound forms likekaksisataa. Similarly,yksi tuhat(1000) producesyksi tuhattain 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 countingzachar(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"orforceVariants="dimension=value,dimension2=value2"on<Unit>(inside<TimeUnits>) or<Fraction>, reusing the samedimension=valuevocabulary as caller-supplied variants. - Programmatic:
NumberToStringConverterOptions.TimeUnitForcedVariants/FractionForcedVariants(keyed likeTimeUnits/Fractions), orCurrencyDefinition.UnitForcedVariants/SubunitForcedVariants— all typedForcedVariantSet, built viaForcedVariantSet.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:OrdinalExceptionsandOrdinalRulesare merged element-by-element (child wins on key conflicts).suffix,prefix, andOrdinalVariantsfall 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):
- Active variant exceptions — from
<OrdinalVariants>, most-specific constraint first. - Base
<OrdinalException>— whole-number match. - Active variant word rules — from
<OrdinalVariants>, most-specific first. - Base
<Ordinal>word rule — last-word match. - Default suffix (±
removeTrailingstrip).
<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:
groupandgroupWithScaletriggers also fire duringConvertOrdinal. 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="& " />
</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. |
Related packages
omy.Utils— containsNumberToStringConverter,NumberToStringConverterOptions, and all built-in culture XML configurations.omy.Utils.Mathematics— providesMathEx.RoundToSignificantDigitsused by the significant-digits precision overload.
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 | Versions 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. |
-
net8.0
- omy.Utils (= 2.0.0-rc.1)
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 |