PaperPony

Arabic not showing in PDF from headless Chrome

The Arabic in your PDF is a row of identical empty rectangles, or it is legible and looks subtly off next to the same document on screen. Those are two different faults with two different fixes, and the second one is what you are left with after most of the advice on this subject.

One command tells you which of the two you have

Terminal
docker run --rm your-image:tag sh -c 'fc-list :lang=ar family | sort -u'

This lists the families that claim Arabic coverage, and its output splits the problem cleanly. Nothing at all means no font in the image can draw the script and you are looking at boxes. A list that includes DejaVu Sans means the script will render, and the question becomes which of the listed families fontconfig actually hands to Chromium, which is a separate decision and usually not the one you want.

When nothing claims Arabic

An image carrying only Liberation
$ fc-list :lang=ar family | sort -u

$ fc-match :lang=ar family
Liberation Mono

The first command prints nothing. The second still answers, because fontconfig always answers: it offers Liberation Mono, which has no Arabic in it whatsoever. Chromium takes that, finds no glyph for any character in the run, and draws every one of them as .notdef.

An image gets into this state without anybody deciding to. The Debian chromium package depends on no font whatsoever, and the one font it would have brought arrives as a recommendation:

debian:bookworm-slim
$ apt-cache depends chromium | grep -i font
  Depends: libfontconfig1

$ apt-cache depends chromium-common | grep -i font
  Recommends: fonts-liberation

A Dockerfile that installs the browser with --no-install-recommends, which is the standard advice for keeping an image small, drops even Liberation and leaves the renderer with nothing. The same Arabic string measured in three images:

Width of the same Arabic string at 16px
no font claims Arabic     130.0 px      boxes
DejaVu Sans                96.0 px      Arabic, wrong face
Noto Sans Arabic           82.9 px      correct

130.0 pixels is not Arabic that came out wide. Every character has collapsed to the same box, and boxes are all one width, so a run of thirteen of them is wider than the real text it replaced. The render exits zero and the log says nothing, which is covered in missing fonts in headless Chrome.

When DejaVu claims it, which is where the advice runs out

Install the usual packages on Debian and ask again. This is a clean debian:bookworm-slim with fontconfig and fonts-noto-core:

Noto Sans Arabic is installed
$ fc-list :lang=ar family | sort -u
DejaVu Sans
DejaVu Sans Mono
Noto Kufi Arabic
Noto Naskh Arabic
Noto Nastaliq Urdu
Noto Sans Arabic

$ fc-match :lang=ar family
DejaVu Sans

Six families claim the script, Noto Sans Arabic among them, and fontconfig hands over DejaVu Sans. The Arabic now renders, the boxes are gone, and it is easy to close the ticket here. You are still on the wrong font, and the way you find out is a document that is not in Arabic.

Reading both fonts' character maps directly, rather than judging by eye how a render looked:

Coverage from the cmap table
Noto Sans Arabic
  Arabic letters (U+0627..U+064A)  28/28
  Arabic-Indic digits (U+0660..)   10/10
  Persian digits (U+06F0..)        10/10
  Persian and Urdu letters         11/11
  Vowel marks (U+064B..U+0652)     8/8
  Punctuation and signs            8/8

DejaVu Sans
  Arabic letters (U+0627..U+064A)  28/28
  Arabic-Indic digits (U+0660..)   10/10
  Persian digits (U+06F0..)        10/10
  Persian and Urdu letters         10/11   missing: ۀ
  Vowel marks (U+064B..U+0652)     8/8
  Punctuation and signs            7/8   missing: ﷼

For Arabic proper, DejaVu is complete: every letter, every vowel mark, both sets of digits. Two characters are missing out of the seventy-six checked, and both of them belong to documents you will eventually be asked to produce. U+06C0 is the Persian heh with hamza, so a Farsi invoice loses a letter mid-word. U+FDFC is the rial sign, so a Saudi invoice loses its currency symbol and keeps the number.

That is the quiet failure. It is one character in a document that otherwise looks finished, in a script most of the people reviewing the output cannot read.

DejaVu does join the letters, whatever you have read

The standard explanation for bad Arabic in a container is that the fallback font cannot connect letters, so words come out as strings of isolated forms. It is worth checking before repeating, because for DejaVu it is not true.

Two observations. The first reads the font's GSUB table, which holds the substitutions that turn a letter into its initial, medial or final form. The second measures what Chromium drew: a word rendered as one string against the same six letters rendered one at a time. A font that joins swaps in narrower contextual forms, so the ratio drops well below 1. A font that cannot join draws the isolated form either way, and the ratio is 1.00.

Both fonts, same word
Noto Sans Arabic
  GSUB scripts          DFLT arab
  joining features      init medi fina rlig
  word as one string    71.7px
  same 6 letters alone  109.9px
  ratio                 0.652

DejaVu Sans
  GSUB scripts          DFLT arab armn brai cans cher cyrl geor grek hani
                        hebr kana lao math nko ogam runr tfng thai latn
  joining features      init medi fina rlig
  word as one string    82.7px
  same 6 letters alone  119.9px
  ratio                 0.690

DejaVu declares the arab script and carries all four joining features, and the measured ratio is 0.690. The letters connect. What differs is metrics and design: the same word is 15% wider than in Noto Sans Arabic, which is what makes a mixed Arabic and Latin table look wrong without anything being identifiably broken.

So the reason to move off DejaVu is the two missing characters and the typography, not joining. If your letters really are unconnected, the font in use is neither of these, and the command at the top of this page will name it.

Make fontconfig choose the Arabic face

Dockerfile
# Debian. fonts-noto-core carries Noto Sans Arabic.
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 font-noto-arabic

COPY 60-arabic.conf /etc/fonts/conf.d/60-arabic.conf
RUN fc-cache -f
60-arabic.conf
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <!-- What sans-serif means when the document names no family. -->
  <alias>
    <family>sans-serif</family>
    <prefer>
      <family>Noto Sans</family>
      <family>Noto Sans Arabic</family>
      <family>DejaVu Sans</family>
    </prefer>
  </alias>

  <!-- The query that decides an Arabic document. Without this it answers
       DejaVu Sans, whatever the block above says. -->
  <match target="pattern">
    <test name="lang" compare="contains"><string>ar</string></test>
    <edit name="family" mode="prepend" binding="strong"><string>Noto Sans Arabic</string></edit>
  </match>
</fontconfig>

The alias and the match rule are not the same instruction. The alias decides what sans-serif resolves to. The match rule decides the other query, the one fontconfig receives with a language attached when a run of text needs a face for a specific script, and that is the query an Arabic document turns on. Ship only the alias and Arabic still goes to DejaVu.

On the document side, name the family and set the language. The lang attribute is what puts ar into the query the rule tests for:

The document
<html lang="ar" dir="rtl">
  <body style="font-family: 'Noto Sans Arabic', sans-serif">
    <p>فاتورة رقم ٤٢</p>
  </body>
</html>
After a rebuild
$ fc-match :lang=ar family
Noto Sans Arabic

Direction is not fixed by any of this

dir="rtl" is in the sample above and it is doing separate work. Font selection decides which shapes get drawn; direction decides the order they are placed in and which edge the line starts from. A document with perfect Noto Sans Arabic and no direction set has every glyph correct and the reading order wrong, and it will not look like a font problem because it is not one.

The place it bites hardest is a table. Column order, text alignment inside cells, and where a number sits relative to its currency all follow direction rather than font, so an invoice can pass a glyph check and still be laid out backwards.

How we handle this

PaperPony renders PDFs from HTML through an API. The render container ships Noto Sans Arabic with the rule above, and the build asks fontconfig the same question this page opens with, then renders the string twice to check that the face it named is the face that drew it:

Two of the rows in the build check
ok   fallback ar      -> Noto Sans Arabic (want Noto Sans Arabic)
ok   render   arabic      default=82.9 named=81.2 drift=2.1%

A wrong answer on any of six scripts fails the build. The quickstart is where to start, and missing fonts in headless Chrome covers the same machinery for the other scripts.