PaperPony

Chinese characters not showing in a PDF

Every Chinese character in the document is an empty box and the digits beside them are perfect. So you extract the text to find out what happened, and the Chinese is in there.

Same markup, two containers
$ pdftotext broken.pdf -
票编号软件开 服务设计实现测试维护合同金额总计付款条款银行账户地址电话

$ pdftotext good.pdf -
发票编号软件开发服务设计实现测试维护合同金额总计付款条款银行账户地址电话

Thirty-six characters went in. Thirty-five come back out of the broken file, and the only one missing is 发, in both places it appeared. Nothing in that output suggests a document nobody can read, which is why a pipeline that checks its own PDFs by extracting the text will keep passing all the way to the customer.

What the file says when you stop reading the text

The same two files
$ wc -c good.pdf broken.pdf
56616 good.pdf
 4262 broken.pdf

Thirteen times the size, for the same thirty-six characters. That gap is the first honest signal, and it is worth knowing on its own, because it works on a PDF somebody emailed you with no access to whatever produced it.

The second one names the culprit outright:

One font, and it is not a CJK font
$ pdffonts broken.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
AAAAAA+DejaVuSans                    CID TrueType      Identity-H       yes yes yes      4  0

A document of thirty-six Chinese characters that embeds DejaVu Sans and nothing else. DejaVu has no CJK at all, so every one of those characters was drawn from the same .notdef box, and boxes have no outlines worth storing.

The working file looks stranger and is worth understanding:

Twenty-three fonts, none of them named
$ pdffonts good.pdf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
[none]                               Type 3            Custom           yes no  yes      4  0
[none]                               Type 3            Custom           yes no  yes      5  0
[none]                               Type 3            Custom           yes no  yes      6  0
...

$ pdffonts good.pdf | tail -n +3 | wc -l
23

Chromium does not subset the CJK face and embed it the way it does a Latin one. It writes the glyphs out as Type 3 fonts, which are drawing procedures rather than a font program, and they carry no name. So the check is asymmetric and you should read it that way: a Latin font alone in a CJK document is a finding, while a pile of anonymous Type 3 entries is what success looks like here.

zh-Hans is not a tag fontconfig knows

Both commands are correct
$ fc-list :lang=zh-Hans family

$ fc-list :lang=zh-cn family | sort -u | wc -l
20

The first prints nothing, on a machine with every CJK font installed. Coverage is indexed against orthography files, and those exist for zh-cn, zh-tw and zh-hk but not for a script subtag, so the query matches no font and says so with a blank line. It reads exactly like an image with no CJK in it.

Your document should still say lang="zh-Hans". Chromium translates before it asks. This only bites when you are the one at the terminal trying to find out what went wrong.

On Debian the packages are enough, until something else adds a font

Clean image, one package
$ docker run --rm debian:bookworm-slim sh -c '
    apt-get update -qq && apt-get install -y -qq fontconfig fonts-noto-cjk > /dev/null 2>&1
    for l in zh-cn zh-tw ja ko; do echo "$l => $(fc-match :lang=$l family)"; done'

zh-cn => Noto Sans CJK SC
zh-tw => Noto Sans CJK TC
ja => Noto Sans CJK JP
ko => Noto Sans CJK KR

All four correct, with no configuration written by anybody. CJK is the pleasant case here: for Arabic and Hebrew the same clean image answers DejaVu Sans no matter what you install, which is covered in Arabic not showing in PDF.

It stops being the pleasant case the moment a second CJK font arrives, and installing a browser is enough to bring two. WenQuanYi Zen Hei is Chinese, IPAGothic is Japanese, neither was asked for, and both claim the same four languages Noto does. With nothing written down to settle the tie:

Same question, after the browser's dependencies land
ja => WenQuanYi Zen Hei,文泉驛正黑,文泉驿正黑
ko => WenQuanYi Zen Hei,文泉驛正黑,文泉驿正黑

Japanese and Korean handed to a Chinese typeface. Alpine gets there by a different road and ends up worse, sending all four languages to the Japanese face with the Noto CJK package installed and nothing else configured:

alpine:3.22 with font-noto-cjk
zh-cn => Noto Sans CJK JP
zh-tw => Noto Sans CJK JP
ja => Noto Sans CJK JP
ko => Noto Sans CJK JP

One typeface family, four languages, Han unification in between

None of those wrong answers produces a box. Chinese, Japanese and Korean share thousands of characters, and a Japanese face has a glyph for nearly everything a Chinese document contains, so the page renders, the text extracts, and the shapes are wrong in the way a native reader notices in the first line and an automated check never will.

Simplified and Traditional are the same trap one level down. A rule matching zh catches zh-TW and zh-HK with it, so a config written for the mainland quietly draws Taiwanese and Hong Kong documents in Simplified forms.

One package on either distribution carries all five regional faces, and then the rules decide which one a document gets:

Dockerfile
# Debian. fonts-noto-cjk carries SC, TC, HK, JP and KR.
RUN apt-get update \
    && apt-get install -y --no-install-recommends fontconfig fonts-noto-cjk \
    && rm -rf /var/lib/apt/lists/*

# Alpine.
# RUN apk add --no-cache fontconfig font-noto-cjk

COPY 60-cjk.conf /etc/fonts/conf.d/60-cjk.conf
RUN fc-cache -f

Order the rules from general to specific, because prepend means the last rule to run ends up first:

60-cjk.conf
<!-- The general rule first. A lang test of "zh" matches zh-TW and zh-HK
     as well, and prepend puts a family at the front of the list, so the
     rule that runs last is the rule that wins. -->
<match target="pattern">
  <test name="lang" compare="contains"><string>zh</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>Noto Sans CJK SC</string></edit>
</match>

<match target="pattern">
  <test name="lang" compare="contains"><string>zh-tw</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>Noto Sans CJK TC</string></edit>
</match>

<match target="pattern">
  <test name="lang" compare="contains"><string>zh-hant</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>Noto Sans CJK TC</string></edit>
</match>

<match target="pattern">
  <test name="lang" compare="contains"><string>ja</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>Noto Sans CJK JP</string></edit>
</match>

<match target="pattern">
  <test name="lang" compare="contains"><string>ko</string></test>
  <edit name="family" mode="prepend" binding="strong"><string>Noto Sans CJK KR</string></edit>
</match>

The document has to hold up its end. A page with no lang attribute gives fontconfig nothing to match on and takes whichever family the list happens to start with, so every CJK document wants lang="zh-Hans", lang="ja" or whichever applies, and a page mixing two of them wants the attribute on the elements rather than only on html.

Line breaking, where the usual CSS advice makes it worse

Once the glyphs are right, the next thing to go wrong in a table is the wrapping, and the reflex fix is the one that breaks it. Four cells, each 130 pixels wide, measured by reading back the position of every character:

Measured in Chromium
long chinese run, default
  |软件开发服务包括
  |设计实现和测试以
  |及后续维护
  3 lines, fits

long chinese run, keep-all   {word-break:keep-all}
  |软件开发服务包括设计实现和测试以及后续维护
  1 lines, OVERFLOWS the 130px cell: content is 336px

unbroken latin token
  |软件开发服务
  |accountspayable@verylongcompanyname.example
  |测试
  3 lines, OVERFLOWS the 130px cell: content is 386px

unbroken latin, overflow-wrap   {overflow-wrap:break-word}
  |软件开发服务
  |accountspayable
  |@verylongcomp
  |anyname.exampl
  |e 测试
  5 lines, fits

Chinese needs no help. It wraps between characters, with no spaces to wrap at, and Chromium already keeps closing punctuation off the start of a line without being asked:

The comma stays where it belongs
软件开发服务,包
括设计、实现和测
试。

word-break: keep-all is what people reach for when a product code splits in the middle, and the second case above is what it costs: the Chinese stops wrapping altogether and runs 336 pixels through a 130 pixel cell. The real overflow is the third case, an address or a URL with nothing to break on, and it is fixed without touching the Chinese at all:

The whole fix
/* A CJK cell needs no word-break at all. Chromium already wraps
   between characters and already keeps closing punctuation off the
   start of a line. */
td { overflow-wrap: break-word; }

How we handle this

PaperPony renders PDFs from HTML through an API. The render container carries the Noto CJK families and the ordering rules above, and the build asks fontconfig for each language separately rather than for CJK as one thing:

Five of the rows in the build check
ok   fallback zh-Hans -> Noto Sans CJK SC (want Noto Sans CJK SC)
ok   fallback zh-Hant -> Noto Sans CJK TC (want Noto Sans CJK TC)
ok   fallback zh-TW   -> Noto Sans CJK TC (want Noto Sans CJK TC)
ok   fallback ja      -> Noto Sans CJK JP (want Noto Sans CJK JP)
ok   fallback ko      -> Noto Sans CJK KR (want Noto Sans CJK KR)

Traditional Chinese is checked apart from Simplified because the two came back the same for a while and nothing said so. The quickstart is where to start, and missing fonts in headless Chrome covers the machinery underneath all of this.