PaperPony

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

One package, clean image
$ 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 Sans

That 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:

Asked of an image holding only DejaVu
$ fc-list :lang=ru family | sort -u
DejaVu Sans
DejaVu Sans Mono
DejaVu Serif

So 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:

Widths at 16px
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  differs

Read 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.

Both rules, and both files on disk
/* 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:

Nothing on disk can draw Cyrillic
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  differs

Every 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:

Dockerfile
# 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-noto

Doing 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.

What the image can draw
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.

Reasonable, and it has a cost
@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:

The Cyrillic rows of the build check
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.