Installing CJK fonts on Arch is one pacman command. Getting fontconfig to hand the right one to sans-serif is where the actual work is, and it's the part nobody writes down.
The symptom that sent me down this road: after removing wqy-zenhei, Chinese text in my UI started looking wrong. Letter spacing was off, digits looked heavy. The fonts were all installed. Nothing was missing. The problem was that a monospace font had quietly won the sans-serif fallback slot for Chinese, and fontconfig was doing exactly what I'd told it to do, which was nothing.
This is the setup I landed on. It's one Arch box, not universal gospel, but the debugging section applies anywhere fontconfig runs.
The short version
Role | Font |
|---|---|
Latin UI | Inter / Noto Sans |
CJK UI | MiSans (closest to macOS PingFang), or HarmonyOS Sans SC |
CJK body text | Noto Serif CJK SC or Source Han Serif CN |
CJK reading / ebooks | LXGW WenKai |
Monospace | Maple Mono NF CN (Nerd Font glyphs included) |
Emoji | Noto Color Emoji |
Installing the packages is not enough. You must set fontconfig priorities explicitly, or a monospace font will steal the sans-serif CJK fallback slot.
Installing
# From the official repos
sudo pacman -S \
noto-fonts \
noto-fonts-cjk \
noto-fonts-emoji \
noto-fonts-extra \
inter-font
# From the AUR
paru -S maplemono-nf-cn # Maple Mono NF CN, the monospace pick
# From the AUR, pick one as your modern CJK UI face
paru -S otf-misans misans-fontconfig # Xiaomi MiSans
# or
paru -S ttf-harmonyos-sans # Huawei HarmonyOS SansRun fc-cache -fv afterwards.
Sans-serif, for UI and web
Package | Font | Notes |
|---|---|---|
| MiSans | My pick. Closest thing to PingFang, ships its own fontconfig |
| HarmonyOS Sans SC | Same tier as MiSans, more geometric |
| AlibabaPuHuiTi 3 | Wide weight range, neutral |
| Noto Sans CJK SC | Install it regardless as the safety net. Slightly soft letterforms |
| Source Han Sans CN | Same source as Noto Sans CJK. Switching between them accomplishes nothing |
| Inter | Latin companion |
Serif, for reading
Package | Font |
|---|---|
| Noto Serif CJK SC (same package as the sans) |
| Source Han Serif CN |
| Noto Serif |
Kai / long-form screen reading
ttf-lxgw-wenkai from archlinuxcn gives you LXGW WenKai, a screen-optimised kai face. Best option for ebooks and long articles.
Monospace
Package | Font | Notes |
|---|---|---|
| Maple Mono NF CN | From the AUR. Nerd Font icons and CJK in one package. Recommended |
| JetBrains Mono Nerd | Ligatures and icons |
| Fira Code Nerd | The classic ligature font |
| Cascadia Code | Microsoft, ligatures |
| Sarasa Mono SC | Strict 1:2 CJK-to-Latin metrics. 800MB, only if you need it |
Emoji and compatibility
noto-fonts-emoji is mandatory. ttf-twemoji and ttf-openmoji are alternatives if you dislike the Google style. For document compatibility, ttf-liberation gives you metric-compatible Arial/Times/Courier substitutes, and ttf-dejavu is a reasonable last-resort fallback.
What to skip
`wqy-zenhei` / `wqy-microhei`. Embedded bitmaps make them mushy at small sizes. Fully superseded by Noto CJK and MiSans.
`adobe-source-han-sans-otc-fonts`. Conflicts with the cn-fonts variant. Use the CN one.
Installing several monospace fonts without touching fontconfig. This is the one that bites. A monospace font can win the sans-serif CJK match and your UI ends up with heavy digits and broken spacing.
Debugging what you actually have
Look before you edit. Every command here is read-only.
Which font does a generic family resolve to?
fc-match asks fontconfig the real question: if someone requests this family, what do you hand them?
# Default sans-serif, no language constraint, usually a Latin font
fc-match sans-serif
# → NotoSans-Regular.ttf: "Noto Sans" "Regular"
# With a language constraint. This is what a web page actually gets
# when Chinese text shows up.
fc-match "sans-serif:lang=zh-cn"
fc-match "serif:lang=zh-cn"
fc-match "monospace:lang=zh-cn"
fc-match emoji
# Simulate a page whose CSS hardcodes a font you don't have
fc-match "PingFang SC:lang=zh-cn"The full fallback chain
This is the most useful one. -s prints the entire sorted list fontconfig walked. The first line is what you actually get.
fc-match -s "sans-serif:lang=zh-cn" | head -10
fc-match -s "monospace:lang=zh-cn" | head -10The first font in the chain that covers the target characters wins. Mushy text, heavy digits, weird spacing: it's almost always because line one is wrong.
Match by codepoint
More precise than a language tag when you want to know which font draws a specific character.
# U+4E2D
fc-match -s "sans-serif:charset=4E2D" | head -3
# U+1F600, an emoji
fc-match -s "sans-serif:charset=1F600" | head -3
# Several at once
fc-match -s "sans-serif:charset=4E2D 4E00 6587 5B57" | head -3Why does this font look bad?
-v dumps every fontconfig property for the match. Useful when a font renders badly but you don't know which knob is responsible.
fc-match -v "sans-serif:lang=zh-cn" | grep -E "antialias|hinting|hintstyle|rgba|autohint|embeddedbitmap"Which config files are loaded?
Load order determines final priority.
fc-conflist | head -40You'll see /etc/fonts/conf.d/*.conf, /etc/fonts/conf.avail/*, and your own ~/.config/fontconfig/fonts.conf. Your user config should appear at the end of the list, otherwise it isn't overriding anything.
Trace the matching itself
When you've added a prefer block and it stubbornly does nothing, FC_DEBUG explains why.
# 1024 = MATCHV, verbose matching
FC_DEBUG=1024 fc-match "sans-serif:lang=zh-cn" 2>&1 | head -80
# 4 = CONFIG, config file application
FC_DEBUG=4 fc-match "sans-serif:lang=zh-cn" 2>&1 | head -40Value | Meaning |
|---|---|
| MATCH, basic matching |
| CONFIG, config loading |
| MATCHV, verbose matching |
| OBJTYPES, font properties |
| Everything. Extremely noisy |
Listing installed fonts
fc-list | wc -l
fc-list :lang=zh-cn | head -20
# Custom format: family | style | file
fc-list -f '%{family[0]}|%{style[0]}|%{file}\n' :lang=zh-cn | head -20
# Which language tags exist on this system
fc-list :lang | tr ',' '\n' | sort -u | head -20A one-shot diagnostic
Save this as ~/bin/font-doctor.sh or paste it straight into a terminal.
echo "===== Generic family, Chinese ====="
for g in sans-serif serif monospace; do
printf "%-12s -> " "$g"
fc-match "$g:lang=zh-cn"
done
echo
echo "===== Fallback chain, top 5 (sans-serif zh-cn) ====="
fc-match -s "sans-serif:lang=zh-cn" | head -5
echo
echo "===== Emoji ====="
fc-match emoji
echo
echo "===== Is the user config loaded? ====="
fc-conflist | grep -E "fontconfig/fonts.conf|conf.d" | tail -5Configuring fontconfig properly
Installing fonts is not configuration. You have to declare CJK priority for all three generic families explicitly. Without that, fontconfig falls back in whatever order happens to cover the character set first, and a monospace font like Maple Mono frequently wins the sans-serif slot.
Principles
- Only touch user config.
~/.config/fontconfig/fonts.conf. Never edit/etc/fonts/conf.d/. - Use `<alias>` with `<prefer>`. fontconfig walks the prefer list in order and stops at the first font covering the target charset for that language.
- Latin fonts first, CJK right behind them. Latin fonts contain no CJK, so fontconfig automatically falls through to the first CJK font in the chain. The structure stays readable.
- Emoji last, so emoji codepoints reliably land somewhere.
- Don't use `<rejectfont>` to ban a font globally. It rejects that font everywhere, including terminals and explicit requests. To exclude something from fallback, just leave it out of the alias chain. A font that isn't in the chain never gets reached by alias resolution.
Applying it
mkdir -p ~/.config/fontconfig
$EDITOR ~/.config/fontconfig/fonts.conf
# paste the template below
fc-cache -fvApplications pick it up on restart. No need to log out of your desktop session.
The template
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<!-- ============ sans-serif: UI, web, apps ============ -->
<alias binding="strong">
<family>sans-serif</family>
<prefer>
<!-- Latin first -->
<family>Inter</family>
<family>Noto Sans</family>
<!-- CJK fallback, in order -->
<family>MiSans</family>
<family>Noto Sans CJK SC</family>
<family>Source Han Sans CN</family>
<!-- Emoji last -->
<family>Noto Color Emoji</family>
</prefer>
</alias>
<!-- ============ serif: body text, reading ============ -->
<alias binding="strong">
<family>serif</family>
<prefer>
<family>Noto Serif</family>
<family>Noto Serif CJK SC</family>
<family>Source Han Serif CN</family>
<family>Noto Color Emoji</family>
</prefer>
</alias>
<!-- ============ monospace: terminal, editors ============ -->
<alias binding="strong">
<family>monospace</family>
<prefer>
<family>Maple Mono NF CN</family>
<family>Noto Sans Mono CJK SC</family>
<family>Noto Color Emoji</family>
</prefer>
</alias>
<!-- ============ Rendering, adjust to taste ============ -->
<match target="font">
<edit name="antialias" mode="assign"><bool>true</bool></edit>
<edit name="hinting" mode="assign"><bool>true</bool></edit>
<edit name="hintstyle" mode="assign"><const>hintslight</const></edit>
<edit name="rgba" mode="assign"><const>rgb</const></edit>
<edit name="lcdfilter" mode="assign"><const>lcddefault</const></edit>
<edit name="embeddedbitmap" mode="assign"><bool>false</bool></edit>
</match>
</fontconfig>Syntax worth knowing
Element | Meaning |
|---|---|
| Maps a generic family to a set of real fonts |
| High-priority fallback chain, taken in order |
| Low-priority fallback. Rarely needed |
| Final fallback when no alias matches |
| Outranks same-named rules from |
| Changes rendering properties: hinting, antialias, rgba |
| Changes the request itself, e.g. rewriting |
Verifying
fc-match "sans-serif:lang=zh-cn" # expect MiSans or Noto Sans CJK SC
fc-match "serif:lang=zh-cn" # expect Noto Serif CJK SC
fc-match "monospace:lang=zh-cn" # expect Maple Mono NF CN
fc-match emoji # expect Noto Color Emoji
fc-match -s "sans-serif:lang=zh-cn" | head -10
fc-match -s "sans-serif:charset=4E2D" | head -3
fc-conflist | head -30Things that will trip you up
Changes don't apply. You didn't restart the application. fontconfig is read once at process start.
`fc-cache` is slow or errors. Add -f to force a rebuild. The first scan over a large font library takes a minute or two.
One specific site or Electron app still looks wrong. Check whether its CSS hardcodes a font you don't have, like font-family: "PingFang SC". Rewrite the request:
<match target="pattern">
<test name="family"><string>PingFang SC</string></test>
<edit name="family" mode="assign" binding="same">
<string>MiSans</string>
</edit>
</match>You want a different font per language. Constrain with <test name="lang">. For example, Japanese only:
<match target="pattern">
<test name="lang" compare="contains"><string>ja</string></test>
<test name="family"><string>sans-serif</string></test>
<edit name="family" mode="prepend" binding="strong">
<string>Noto Sans CJK JP</string>
</edit>
</match>Malformed XML fails silently. It isn't reported, it's just ignored. Use fc-conflist to confirm your file is loaded, and FC_DEBUG=1024 fc-match sans-serif to watch it parse.
Three things I learned the hard way
Removing `wqy-zenhei` made my UI worse, and it wasn't the font's fault. The sans-serif CJK fallback slot got taken over by a monospace font. The fix was writing explicit fontconfig priorities, not reinstalling anything.
Source Han Sans and Noto Sans CJK are the same typeface. Same origin, different branding. Swapping one for the other changes nothing. If you want a different look, go to MiSans or HarmonyOS Sans.
Maple Mono NF CN already renders CJK, so you probably don't need Sarasa Mono. Sarasa's strict 1:2 metrics are genuinely nice, but it's an 800MB package. Only worth it if you specifically need that alignment.




No comments yet