If you generate images with Chinese, Japanese, or Korean text in Python, there is a category of bug you have almost certainly shipped: the text rendered, nothing threw, and the output is wrong.
Not "wrong" as in a stack trace. Wrong as in a user looks at the poster and says "something feels off" without being able to name it. Or worse: a character is simply missing from the image and nobody notices until print.
I collected the three that bite hardest while building cjk-text-render, a single-file helper for drawing CJK text with Pillow. All three are silent by construction.
Trap 1: PingFang.ttc index 0 is not "Regular"
On macOS, /System/Library/Fonts/PingFang.ttc is a TrueType Collection. The order of faces inside it is not weight order. It is region order:
| index | face | notes |
|---|---|---|
| 0 | PingFang HK Regular | Hong Kong Traditional, the one people accidentally use |
| 1 | PingFang TC Regular | Taiwan Traditional |
| 2 | PingFang SC Regular | Simplified Chinese, the one you want |
| 3 / 4 / 5 | HK / TC / SC Medium | |
| 6 / 7 / 8 | HK / TC / SC Semibold | use 8 for bold Simplified |
| 9 / 10 / 11 | HK / TC / SC Light |
Because index defaults to 0, the idiomatic-looking call is the wrong one:
# WRONG: renders everything in Hong Kong Traditional glyph forms
font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40)
# RIGHT: Simplified Chinese Regular
font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40, index=2)
Two things go wrong when you use index 0. First, every character is drawn in the Hong Kong Traditional glyph variant. Simplified Chinese and Traditional Chinese share a large number of codepoints but not their shapes, so you get characters that are recognisably the wrong form while still being the right codepoint. Second, and much nastier: some Simplified characters are absent from the HK face. 稳 is a common example. Pillow draws a blank space. No exception, no warning, no log line.
If you want to check what you actually loaded, inspect the face rather than trusting the index:
from PIL import ImageFont
f = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40, index=2)
print(f.getname()) # -> ('PingFang SC', 'Regular')
Print getname() in CI. It is a one-line assertion that catches an entire class of platform-dependent regressions before they reach a human.
Trap 2: missing glyphs are drawn as blank, not raised
This is the one that should be an exception and is not.
font = ImageFont.truetype("NotoSansCJK-Regular.ttc", 40)
draw.text((0, 0), "文字と漢字の混在", font=font, fill="black")
If the loaded face does not contain one of those characters, Pillow does not raise KeyError, does not log, and does not fall back to another face. It draws nothing for that character. You get a gap in the middle of a word.
The fix is an explicit audit before you render. Pillow exposes what you need through font.getmask, but the ergonomic version is a helper that raises on the first missing codepoint:
from cjk_render import audit
audit(["all the text you are about to draw"]) # raises RuntimeError on any missing glyph
The design decision here matters: make it crash. A script that explodes at build time is strictly better than a delivered image with a hole in it. The failure mode you cannot afford is the one that ships.
Run audit() on your real strings, not on sample text. The characters that go missing are always the domain-specific ones — a rare surname on a certificate, a technical term on a data card.
Trap 3: line-breaking CJK by width instead of by punctuation
CJK has no spaces, so the obvious wrapping implementation — accumulate characters until the line exceeds max_width, then break — produces this:
知道你在打
杂
The word 打字 has been cut in half. Readers notice immediately. Latin line-breaking intuition simply does not transfer.
There are three rules worth encoding:
-
Prefer punctuation. Look back up to ~12 characters from the overflow point for the nearest of
,。、:;!?)」』】—…and break there if one exists. -
Never split numeric units. If breaking would put
20on one line and+on the next, the reader parses two numbers instead of one quantity. - No orphaned closing punctuation. A full stop or comma must never begin a line.
from cjk_render import wrap
lines = wrap(draw, "一段很长的中文正文……", font, max_w=900)
A short list of things that are not traps but will bite anyway
Once the three silent failures are handled, a few loud-but-annoying ones remain:
-
Save PNG, not JPEG. Dense CJK strokes at small sizes pick up chromatic noise from JPEG compression. If you must use JPEG, keep
quality >= 95. -
Auto-fit functions have an off-by-one that inverts them. In a
while size > min_sizeshrink loop, ifmin_sizeis not strictly below the smallest starting size you call it with, the loop body never executes and your "shrink to fit" silently leaves the text at its original size, overflowing the canvas. - Cap the hierarchy at three levels. On a 1080x1080 thumbnail, a fourth text level is decoration, not information.
Why this is worth caring about
AI image generation still mangles CJK text, so any design with a lot of it — posters, data cards, marketing images, before/after comparisons — has to be drawn in code. That makes these three traps load-bearing: they are on the critical path of an entire category of work, and all three are silent.
The library is a single file with no dependencies beyond Pillow. Take it, or take the three checks and port them into whatever renderer you already have. Just do not rely on the renderer to tell you it failed. It will not.
Repo: github.com/yff124/cjk-text-render (MIT)
Top comments (0)