Russian and Cyrillic not rendering in a PDF
You already added the font packages to the image, rebuilt, and the Russian is still squares. Or worse, it came back but it is visibly not the typeface the English is in, and nobody can tell you why.
Cyrillic is the one script on this list where the fonts are usually innocent. Reaching for the Dockerfile is the reflex, and it is normally the wrong layer.
Almost anything you installed already covers Cyrillic
$ docker run --rm debian:bookworm-slim sh -c '
apt-get update -qq && apt-get install -y -qq fontconfig fonts-noto-core > /dev/null 2>&1
fc-match :lang=ru family'
Noto SansThat is the Noto core package and nothing else, with no fontconfig rules written by anybody. Cyrillic resolves correctly on the first try, which Arabic and Hebrew on the same image do not.
The safety net underneath is wider still. DejaVu ships in most base images that carry any font at all, and it has covered Cyrillic for twenty years:
$ fc-list :lang=ru family | sort -u
DejaVu Sans
DejaVu Sans Mono
DejaVu SerifSo if your image has DejaVu, or Noto, or the Liberation and DejaVu pair a browser package tends to drag in, the glyphs are on disk. Something above the font layer is refusing to use them, and the usual something is a web font.
The self-hosted web font is the one that is missing a file
Web font subsets are split by script. Download a family from any of the usual mirrors and you get one file per subset, and the Cyrillic one is a separate download that a Latin-reading developer has no reason to notice is missing.
Here is what that costs, measured in a container that has Noto installed, with the same string drawn under different stacks. The reference row is plain sans-serif:
sans-serif (reference)
latin 72.9px
cyrillic 35.9px
numero 17.2px
latin subset, sans fallback
latin 76px differs
cyrillic 35.9px same as reference
numero 17.2px same as reference
both subsets
latin 76px differs
cyrillic 35px differs
numero 16px differsRead the middle block. The Latin is 76 pixels, so the web font loaded and drew it. The Cyrillic is 35.9, to the tenth of a pixel the same as the reference, which means the web font did not draw it and the installed face did. Chromium falls back for each character it cannot find, quietly and without a console message, so the page renders with the brand typeface for English and something else for Russian.
That is the answer to the second half of the complaint. Nothing is broken enough to report, and the document is wrong to anybody who can read it.
One character to check before you believe the fix
№ is the numero sign, U+2116, and it appears on almost every Russian invoice. It is not in the Latin subset. It is in the Cyrillic one, next to the letters, which the third row of both tables shows: 17.2 pixels from the fallback, 16 from the real file.
So a document that is otherwise entirely Latin still needs the Cyrillic file the moment it prints an invoice number in the Russian style. If you are checking a fix by eye, the Cyrillic letters are the obvious thing to look at and № is the one that catches a half-done job.
/* The Latin file. Nothing in this range is Cyrillic. */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-sans-latin-400.woff2') format('woff2');
font-display: block;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
}
/* The file people forget. Note where U+2116 lives. */
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-sans-cyrillic-400.woff2') format('woff2');
font-display: block;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}unicode-range changes nothing about which glyphs appear. Measured with it and without it, the widths are identical: the fallback happens either way. What it buys is that a Latin-only document never downloads the Cyrillic file. Declare it because it is free, not because it fixes anything.
The image that really has no Cyrillic, and the two ways out
The squares are real in one case: an image carrying Liberation and nothing else, which is what a browser package installs when the Dockerfile uses --no-install-recommends. The same measurement there:
sans-serif (reference)
latin 100px
cyrillic 40px
numero 10px
latin subset, sans fallback
latin 76px differs
cyrillic 40px same as reference
numero 10px same as reference
both subsets
latin 76px differs
cyrillic 35px differs
numero 16px differsEvery Latin-only row gives 40 pixels, the reference width, and the reference is boxes. Then look at the last block: 35 pixels and 16, the same numbers the working container produced. The web font supplied the Cyrillic over the wire in an image that has no Cyrillic on disk at all.
Which is the useful part. There are two independent fixes and they work in different places. Ship the Cyrillic subset and the document carries its own glyphs anywhere. Or install a font and stop depending on the network:
# Debian
RUN apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-noto-core \
&& rm -rf /var/lib/apt/lists/*
# Alpine
# RUN apk add --no-cache fontconfig font-notoDoing both is reasonable and cheap. If you only do the first, the render has to wait for the file to arrive before it prints, which is what font-display: block above is for. Whatever prints the page should be waiting for the network to go quiet rather than capturing as soon as the DOM is ready, or the swap lands after the PDF does.
docker run --rm your-image:tag sh -c 'fc-list :lang=ru family | sort -u'The trap waiting on the other side of the fix
Once both files are served, the next thing to get wrong is local(). It looks like a free optimisation: name the family, and any machine that already has it installed skips the download.
@font-face {
font-family: 'Noto Sans';
src: local('Noto Sans'), local('NotoSans-Regular'),
url('/fonts/noto-sans-cyrillic-400.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}Whose copy is it, though. A reader with Noto Sans installed gets their build of it, which is not the build you subsetted, and Cyrillic is where the two are most likely to differ because it was extended over several releases. The browser preview and the printed PDF then disagree on machines you will never see, and every report of it will be impossible to reproduce.
Which way to go depends on what the file is for. local() first is right for the renderer, where the family is installed in the image you control and skipping the fetch means opening no socket at all. It is wrong for anything a reader loads, where the point is that everybody gets identical outlines. The same stylesheet cannot do both, so generate the two variants rather than picking one and hoping.
How we handle this
PaperPony renders PDFs from HTML through an API, and does both of the things above. The Noto families are installed in the render container, and the free invoice generator serves the same faces as subsets from our own origin, split exactly as the rules on this page are, so the preview in the browser and the printed PDF draw from matching outlines. The build checks the installed half:
ok fallback ru -> Noto Sans (want Noto Sans)
ok render cyrillic default=79.8 named=79.2 drift=0.8%The second row is the one that would catch a font resolving correctly and drawing wrongly. The quickstart is where to start, and missing fonts in headless Chrome covers the scripts where the image really is the problem.