One slide, %n slides – support of plural forms in different languages

Why we needed this

Until last week, a counted string in the Collabora Online web UI looked like this: n === 1 ? _(‘insert 1 slide’) : _(‘insert %1 slides’).replace(‘%1’, n)

That code is correct English and broken localization. It hard-codes a rule that only two-form languages follow: that “one” means exactly 1 and everything else is “many”. Most of the languages we ship do not work that way.

Russian has three forms. The first covers 1, 21, 31 and 101 – but not 11. The second covers 2 to 4, 22 to 24, and so on. The third covers 5 to 20, and anything ending in 11 to 14. Polish has four. Arabic has six, with separate forms for zero, one, two, a few, many and other. Japanese has one. French puts 0 in the singular, English puts it in the plural.

Across the 319 catalogue files in browser/po there are 22 distinct plural rules. Handing a translator two English strings and picking between them in JavaScript decides the grammar on their behalf, and for most of those 22 rules it decides wrong. A Russian translator faced with “insert 1 slide” and “insert %1 slides” has no way to express the form that 21 needs, because our code will never ask for it.

Here is what the same count means across a few of the languages we ship, written as the form index the language asks for:

n =

0

1

2

5

21

101

111

English

1

0

1

1

1

1

1

French

0

0

1

1

1

1

1

Japanese

0

0

0

0

0

0

0

Russian

2

0

1

2

0

0

2

Polish

2

0

1

2

2

2

2

Arabic

0

1

2

3

4

5

4

No amount of care at the call site reproduces those columns. The only code that can is the rule the language itself ships in its catalogue header.

What that looked like in our code

The problem was not theoretical. Every workaround was in the tree.

The most honest one was a local helper in the AI chat sidebar, which had eleven pairs of strings behind it:

const one = (n, singular, plural) => n === 1 ? singular : plural.replace(‘%1’, String(n));

Then there was the parenthesised-s, which pushes the problem onto the reader:

_(‘Delete section “%1” and its %2 slide(s)?’)

There was the string that is simply always plural, and reads wrong exactly once:

_(‘Repeat in {0} seconds’)

There was a string with two independent counts, which no single gettext entry can express at all, because gettext selects on one number: _(‘Table with {0} rows and {1} columns’) And there was a set that had quietly stopped mattering: the user-list count built “%n users”, “1 user” and “0 users” and assigned them to a local variable that nothing read. The last consumer went away in April 2024 when the status bar copy of the user list was removed, and the computation was left behind. Translators kept those three strings current in every language for over two years, for a label that was never displayed. That one we deleted rather than converted.

Where the engine stands

The engine has had proper plural support for years, and it works. A string is declared with the NNC_ macro in a module’s strings.hrc, extracted by xgettext with –keyword=NNC_:1c,2,3, carried through the po files as msgid_plural with msgstr[0..n], compiled by msgfmt into the module’s .mo, and resolved at runtime by Translate::nget(), which is a thin wrapper over boost::locale::npgettext. Modules expose it as a ResId overload taking a count, so a call site reads ScResId(STR_SELCOUNT_ROWARG, nRows). It is correct per view in Collabora Online, because SvtSysLocale::GetUILanguageTag() returns the requesting view’s language when running under the kit, so two users editing the same document in different languages each get their own rule. What is striking is how little it is used: nineteen plural strings in the whole engine – ten in Writer, six in Calc, two in the PDF filter, one in Impress. Of the 184 catalogue files per language, across 131 languages, exactly four contain a plural entry. And two of the engine’s localization routes cannot express plurals at all, by construction: .ui files are translated at runtime through a lookup that takes no count, and .xcu registry strings are merged into static per-language values at build time, with no runtime call to hand a number to. That is where the 47 remaining “(s)” strings in .ui files live. Fixing one of those means moving the string into C++ and setting the widget label at runtime; there is no flag that makes the markup itself plural-aware.

What changed in browser

The web UI now has _n(): _n(‘insert %n slide’, ‘insert %n slides’, count)

It hands both English forms to the translator as a single gettext plural entry, picks the form the target language asks for, and substitutes the count for every %n. Other placeholders, %1 and {0}, are left to the caller. Note that the singular carries %n too: in Russian its form also covers 21 and 101, so hard-coding “1” there would be the same bug in a new place.

The forms travel in the per-language table we already ship, keyed the way a compiled .mo file keys a plural entry: the two source strings joined by a NUL byte, mapping to the translated forms joined by NUL, with the language’s Plural-Forms header stored under a reserved NUL key. A NUL can never occur in a msgid, so neither key can collide with a real string. The pleasant consequence is that the lookup is an ordinary toLocaleString() call, and none of the three tables we ship – the browser’s l10n.js, the admin console’s l10n-for-node, or the mobile app’s bundled LOCALIZATIONS object – needed to learn that plurals exist.

The rule itself arrives as a small C expression, for example:

nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4

&& (n%100<10 || n%100>=20) ? 1 : 2);

We parse it into a tree of closures rather than handing it to Function() or eval(), both of which our eslint configuration forbids and a tightened Content-Security-Policy at an integrator would refuse to run. The parser agrees with the C expression for every n from 0 to 3000 on all 22 headers currently in po/, and never selects a form outside nplurals. A mocha suite covers seven languages’ rules and the table lookup.

The build side is small: xgettext gained –keyword=_n:1,2, po2json.py emits the plural entries and the rule, and the Options-dialog generator does the same – its minimal po reader now understands msgid_plural and msgstr[N], which it used to mis-parse.

For the two-count case, there is no clever answer: gettext selects on one number. The table announcement is now split the way the engine splits the Writer word count, one plural entry per count composed by a third string, which lets a language pick one form for the rows and a different one for the columns in the same sentence: Таблица: 5 строк, 21 столбец

Fifteen of the 1584 strings in browser/ are now plural entries. The rest of the counted strings are still out there.

What we’re asking of developers

If you touch a UI string that contains a number, use _n(). Concretely:

Write _n(‘%n row’, ‘%n rows’, count), not a ternary over two _() calls. Put %n in both forms – never write the digit 1 into the singular, because in half the languages we ship that form also covers 21, 31 and 101.

Never ship “(s)”. It is not a plural; it is a note to the reader apologising for one.

If a sentence has two independent counts, do not try to force it into one entry. Make each count its own plural string and compose them with a third, and add a TRANSLATORS: comment explaining what the placeholders already hold – xgettext now extracts those.

In a plain .js file, add _n to the /* global */ comment at the top, or eslint will stop you.

If the string lives in the engine rather than the web UI, the equivalent is NNC_ in the module’s strings.hrc plus the ResId overload that takes a count. The machinery is there and has been for years; it is simply under-used.

And if you find a counted string that predates all this, converting it is a two-line change. The catalogue, the build, and the tests are already waiting for it.

Leave a Reply

Get the latest updates to your inbox