PDFPipe

European scripts / Cyrillic

Cyrillic text in a PDF

Most Latin fonts include some Cyrillic, usually the Russian subset, and several languages written in Cyrillic need letters or letter shapes that subset does not have.

A font with the characters is not enough

Two separate gaps. The first is coverage: a font advertising Cyrillic support frequently means Russian, and Serbian, Macedonian, Ukrainian and the Central Asian languages use letters outside that range which then fall back or come out as tofu. The second is more interesting: several letters have different correct printed forms depending on the language. Bulgarian and Serbian italic forms of letters such as be, ghe, de, pe and te are genuinely different shapes from the Russian ones, not stylistic variants. Fonts that support this expose the alternates through the locl OpenType feature, which the engine selects using the language of the text. Without a lang attribute you get the font's default, which is almost always Russian.

What the failure looks like

For the coverage gap, a scattering of tofu or of characters in a visibly different typeface. For the localised forms, text that is entirely legible and that a Serbian or Bulgarian reader will describe as looking Russian, which is a real complaint and not a stylistic preference.

Line breaking

Ordinary word-space breaking, the same as Latin. Hyphenation dictionaries differ per language, so a hyphenation setting that works for Russian will hyphenate Ukrainian incorrectly unless the language is declared.

The markup and the CSS

The language attribute is doing real typographic work in every one of these examples. It is what lets the engine choose a regional glyph form, a case mapping or a hyphenation dictionary, and leaving it off is the most common single omission in a multilingual document.

html
<p lang="ru" class="cyrillic">Ваш счёт готов.</p>
<p lang="sr" class="cyrillic">Ваш рачун је спреман.</p>

<style>
@font-face {
  font-family: "Noto Sans";
  src: url("https://assets.example.com/fonts/noto-sans-cyrillic.woff2")
       format("woff2");
  /* Cyrillic, Cyrillic Supplement and Extended-A. A subset covering only
     the Russian alphabet is the usual cause of missing Serbian letters. */
  unicode-range: U+0400-052F, U+2DE0-2DFF, U+A640-A69F;
}

.cyrillic {
  font-family: "Noto Sans", sans-serif;
  /* locl selects the localised letter forms using the element language.
     Most engines apply it by default; asking explicitly documents that
     the behaviour is intentional. */
  font-feature-settings: "locl" 1;
}</style>

The mistake people make

Treating Cyrillic as one thing. It is a script shared by dozens of languages the way the Latin alphabet is, and a font stack, a subset range and a hyphenation setting chosen for Russian will be wrong for at least one of the others. Declare the language on the element and let the font do its job.

How to test this without reading the language

Render a string you know is correct, and compare it against a reference rendering from a system that definitely handles the script, such as a browser on a machine with the language installed. Comparing against nothing tells you only that characters appeared, and for most of the scripts on this site characters appearing is not the same as text being right. Where you can, get one native reader to look at one page once; it is faster than any amount of specification reading.

Frequently asked

Why does Cyrillic look wrong when every character is there?

For the coverage gap, a scattering of tofu or of characters in a visibly different typeface. For the localised forms, text that is entirely legible and that a Serbian or Bulgarian reader will describe as looking Russian, which is a real complaint and not a stylistic preference. The underlying reason is that a font is two things: a set of glyphs and a set of layout tables that say how those glyphs combine and position. Character coverage is the first half. Most of the failures on this page are the second half.

Does the lang attribute actually change the rendering?

Yes, and it is not an accessibility nicety here. It selects regional glyph forms where a code point has more than one correct shape, it picks the case mapping for a text transform, it chooses a hyphenation dictionary, and it triggers the locl OpenType feature. A document with no lang attribute gets whatever the font defaults to, which is usually right for one language and wrong for the others.

Can I subset the font to keep the file small?

Carefully, and not with a tool configured for Latin. Subsetters routinely drop the GSUB and GPOS tables that shape and position glyphs, because for Latin those are largely optional. The subset renders without error and every complex feature in it is broken. Test a subset by rendering real text and looking at it, never by checking that the file got smaller.

Related writing systems

Scripts with a related mechanism, and one from each of the other families.

Paste a real string in this script into the playground and compare the output against a reference. It is the only test that means anything if you do not read the language.