WeChat Mini Programs Break Custom Chinese Fonts in Five Different Ways
I built a WeChat Mini Program called JuShi (Sentence Picker). Its slogan is six characters: Pick a sentence you like.
The business logic isn't flashy: a daily sentence pick, categorization by mood and theme, collections and topics, favorites sync, check-in to unlock paper styles, and a detail page that turns sentences into posters. Node.js + MongoDB + admin backend—everything you'd expect is there.
What really held up progress before launch wasn't the APIs or the layout—it was custom Chinese fonts.
In the WeChat Developer Tools, it looked like Fangsong and handwriting fonts were already applied. On a real device, the top bar was still in Heiti, the poster was still in Heiti, and the text in the search box was double-stacked. The console cycled through three errors:
url scheme is invalidnetwork errorloadFontFace:fail
The official documentation is only a few lines. Half the solutions on forums involve dropping a ttf file into /static and loading it via wxfile://—it works in the developer tools, but not on a real device.
This article clearly documents the solution that finally got JuShi running, serving as a reference manual for anyone else tormented by fonts. You don't need to care about JuShi's business logic; just take the loading pipeline, error reference table, and checklist at the end.
1. Define the Goal First
JuShi needs three sets of fonts:
| Scenario | Font | Approximate Full Package Size |
|---|---|---|
| Brand top bar "JuShi" | ZhiMangXing | Several MB |
| Global UI | HuawenFangsong | ~7MB woff |
| Chinese sentences / posters | MaShanZheng | ~3.7MB woff |
| English sentences | Fangsong | MaShanZheng has almost no Latin characters |
There are also three hard constraints:
- The main package must be under 2MB (excluding subpackages). The three complete font sets total over a dozen MB; putting them directly into the main package would fail the review.
wx.loadFontFace'ssourceonly accepts https and Data URLs. Local paths,wxfile://, andhttp://usrare not "occasional failures" on real devices—they are explicitly unsupported.- Rendering text in the UI and drawing text on a Canvas poster use different APIs. Pages use
loadFontFace, while posters use Canvas 2D'scanvas.loadFont+ local files. Mixing them will definitely cause problems.
If your requirement is just "swap a few titles for a handwriting font," you don't need a full Chinese font library; a subset is enough. If you also need poster export, you must design the two separate pipelines from day one.
2. How WeChat Actually Allows Font Loading
Memorize the official rules first; all the pitfalls grow from here.
wx.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: 'url("https://your.domain/font.woff")',
// or Data URL:
// source: 'url("data:font/woff;charset=utf-8;base64,AAAA...")',
scopes: ['webview'],
success() {},
fail(err) { console.warn(err) }
})
Key points:
source can only be:
url("https://...")a public https URL, the domain must be configured in the downloadFile legal domain listurl("data:font/woff;charset=utf-8;base64,...")
source cannot be:
/static/fonts/xxx.ttf(this is an intra-package path, used for<image>)wxfile://usr/xxx.ttfhttp://usr/xxx.ttf- Relative paths like
./fonts/xxx.woff
The developer tools are more lenient with local paths, so many people think they've "already succeeded" in the simulator, only to find the font hasn't changed at all during review or on a real device. When debugging custom fonts, treat the real device as the source of truth.
scopes:
| Value | Effect | How JuShi uses it |
|---|---|---|
webview |
<text>, <view> elements on the page |
UI fonts only use this |
native |
Native components, old Canvas API | Data URL / local path with native often causes network error on real devices and can fail the entire loadFontFace call |
Conclusion: For UI loading, do not write native and Data URL in the same call. If you need native input elements to also change fonts, wait until you have an https full font and then make a separate call with scopes: ['native']. Ignore failures; don't let them affect the already successful webview font.
global: true: Load once, and subsequent pages can use this family. JuShi initiates loading in App.onLaunch to avoid each page fighting its own loadFontFace battle.
3. Final Architecture: Two Font Sets, Two Paths
JuShi's final breakdown looks like this:
Launch
├─ Main package subset (base64 JSON)
│ └─ Data URL → loadFontFace(scopes: webview)
│ └─ First screen has fonts immediately (may lack rare characters)
│
├─ Simultaneously write subset woff to USER_DATA (for poster use)
│
└─ Delay ~1s
└─ https download full woff to USER_DATA
├─ Verify file size is normal
├─ loadFontFace(https, webview) overwrites UI
└─ loadFontFace(https, native) attempt for input (ignore failure)
Export Poster
├─ Wait for file in USER_DATA
├─ Canvas 2D: set canvas.width / height first
├─ canvas.loadFont(local path)
├─ Use loadFont's return value as the family for ctx.font
└─ Wait ~120ms before drawing
Can be summarized in one sentence:
Page text rendering uses Data URL / https; poster text rendering uses local files.
4. File Size: Subset First, Then Consider the Cloud
A complete Chinese font library cannot enter the main package. JuShi uses fontTools to create subsets, then converts to woff, then to base64 JSON, which the main package requires.
4.1 Three Subsetting Methods
- Brand short-phrase subset (ZhiMangXing): Only keep characters used in titles like "JuShi" and "Pick a sentence you like". The size can be compressed to very small. Must be visible on the homepage immediately, so it must be in the main package.
- UI text subset (Fangsong): Scan the project's
.vue/.jsfiles for all Chinese characters used, plus punctuation and ASCII. Enough to support tabs, buttons, and descriptive text. - Body text common character subset (MaShanZheng): Sentences will encounter more characters. The subset first ensures common characters can be displayed; missing characters wait for the cloud full package to cover.
Don't try to stuff the GBK full character library into the main package. The 2MB limit is a law of physics.
4.2 Change the family Name
After subsetting, use fontTools to change the name table to the name you write in CSS, e.g., HuawenFangsong, MaShanZheng, ZhiMangXing. Otherwise, CSS writes A, but the font internally is still called "华文仿宋" or some English string, and the real device won't match them.
4.3 How to Carry It in the Main Package
Don't just drop .ttf into static and read the file at runtime—that will again hit an illegal scheme. JuShi encodes the subset woff into JSON:
{
"base64": "d09GRgABAAAA...",
"format": "woff",
"family": "HuawenFangsong"
}
Construct the Data URL at startup:
function dataFontSource(format, base64) {
const mime = format === 'woff' ? 'font/woff' : 'font/ttf'
return 'url("data:' + mime + ';charset=utf-8;base64,' + base64 + '")'
}
uni.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: dataFontSource('woff', payload.base64),
scopes: ['webview']
})
JSON will get larger (base64 is about 4/3 the size of the original file), so subsetting must be aggressive. For fonts like ZhiMangXing that are "only used for a few characters," put the subset in the main package; put the full Fangsong / MaShanZheng on your own https domain, e.g., https://your.domain/public/fonts/xxx.woff.
4.4 The Order of Cloud Override Cannot Be Reversed
Previously, loadFontFace was called directly on the cloud URL before the file was uploaded to the server. The real device got a string of 404s, network error flooded the console, and users saw no fonts.
Correct order:
- Subset Data URL succeeds first; the page already has custom fonts (fallback for missing characters).
downloadFilesaves the full woff towx.env.USER_DATA_PATH.statchecks the file size; Fangsong should be at least several MB. If it's noticeably small, you downloaded an HTML error page.- After confirming the download works, use the same https URL to call
loadFontFaceto overwrite the UI.
The same family can be loaded twice: first the subset, then the full package. Just call loadFontFace again.
5. How to Write UI CSS
loadFontFace success only registers the family; CSS must also match, and don't accidentally kick out your own font.
page {
--js-font-serif: HuawenFangsong, 'Songti SC', 'STSong', serif;
--js-font-quote: MaShanZheng, HuawenFangsong, 'Songti SC', serif;
--js-font-zhimang: ZhiMangXing, HuawenFangsong, 'Songti SC', serif;
font-family: var(--js-font-serif);
}
.js-font-quote {
font-family: var(--js-font-quote);
}
.js-font-zhimang {
font-family: var(--js-font-zhimang);
}
.iconfont {
font-family: 'iconfont' !important;
}
5.1 font-weight: 700 Makes the Font "Look Like It Didn't Take Effect"
Many free Chinese handwriting and Fangsong fonts only have Regular. Writing font-weight: 700 or font-weight: 600 for titles in the mini program causes the engine to think "this font has no Bold," and it falls back directly to the system Heiti.
The symptom is: the family has loaded successfully, but the top bar is still Heiti.
For brand, handwriting, and Fangsong titles, always use:
.brand__name {
font-weight: 400; /* Don't use 700 */
font-family: ZhiMangXing, var(--js-font-zhimang);
}
If you need "heavier" weight, change font size, letter spacing, or color—don't use bold.
5.2 Don't Mix Chinese and English in the Same Handwriting Font
MaShanZheng has almost no complete Latin alphabet. Forcing English sentences onto it results in square boxes or a fallback to another font, tearing the style between Chinese and English on the same line.
JuShi uses a very simple check: if the text contains CJK characters, use MaShanZheng; otherwise, use Fangsong.
const CJK_RE = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/
export function quoteFontClass(text) {
return CJK_RE.test(String(text || '')) ? 'js-font-quote' : ''
}
Template:
<text :class="quoteFontClass(item.content)">{{ item.content }}</text>
5.3 Native input Does Not Inherit page Font
<text> uses the webview font, but <input> / <textarea> are native components and do not inherit page { font-family }.
What you can do:
input, textarea {
font-family: HuawenFangsong, 'Songti SC', 'STSong', serif;
font-weight: 400;
}
Also write the family again for placeholder-style (placeholder-class often gets lost on native components).
What you must not do: overlay a <text> on top of the input to simulate a custom font. When focus is lost, the native text and the overlay stack on top of each other, creating a ghosting effect. JuShi stepped on this landmine and has already removed it.
To reliably change the input font, you must wait for the https full font to load successfully, then separately:
uni.loadFontFace({
global: true,
family: 'HuawenFangsong',
source: 'url("https://your.domain/public/fonts/HuawenFangsong-Regular.woff")',
scopes: ['native']
})
If it fails, it fails. The search box continues to use the system font, but at least it won't crash the page font.
6. Posters: A Completely Different Path
The "Download Poster" feature on the detail page is a Canvas composition: background image + sentence + author. Here, loadFontFace is not enough.
The old canvas component with custom fonts often silently falls back on real devices. JuShi uniformly uses Canvas 2D (type="2d").
6.1 Files Must Be in USER_DATA
canvas.loadFont consumes a local file path, for example:
wx.env.USER_DATA_PATH + '/HuawenFangsong.full.v1.woff'
The source can be: writing the subset base64 into the file via writeFile at startup, or downloadFile for the full package. Don't directly pass the intra-package /static/fonts/xxx.ttf to canvas—if the path loses its leading /, it gets concatenated into /pages/currentPage/static/..., resulting in a 500 error.
6.2 Set Width/Height First, Then loadFont
Changing canvas.width / canvas.height resets the context. The order must be:
canvas.width = W * dpr
canvas.height = H * dpr
ctx.scale(dpr, dpr)
// Only after this, loadFont
const family = canvas.loadFont(localPath) || 'HuawenFangsong'
await sleep(120) // Real device needs a breather after registration
ctx.font = '42px ' + family
ctx.fillText(content, x, y)
6.3 ctx.font Writes Only One family
Web habit:
ctx.font = '42px MaShanZheng, HuawenFangsong, serif'
On a real device Canvas, seeing the system fallback later in the list often directly ignores the custom font. Write only one:
ctx.font = '42px ' + family
canvas.loadFont(path) on some base library versions returns the font's internal real family string. You must use this return value; don't assume the alias you used in CSS.
function registerCanvasFont(canvas, filePath, fallbackFamily) {
if (!canvas || !filePath || typeof canvas.loadFont !== 'function') {
return fallbackFamily
}
try {
const ret = canvas.loadFont(filePath)
if (typeof ret === 'string' && ret.trim()) return ret.trim()
canvas.loadFont(filePath, fallbackFamily)
} catch (e) {
console.warn('[poster] loadFont', e)
}
return fallbackFamily
}
6.4 Background Images Must Also Be "Paths Canvas Recognizes"
For intra-package images used on canvas, ensure they are root paths like /static/... or copy them to USER_DATA first. https images must be downloadFile'd first. When the WeChat privacy agreement hasn't been consented to, saveImageToPhotosAlbum will get stuck; handle privacy authorization before saving.
7. Subpackaging: Fonts Needed on the Homepage Must Not Go in Subpackages
I once put the font JSON into a media subpackage and used require.async in the main package. After packaging, the path was invalid, and the homepage top bar was always the system font.
WeChat also has a rule: The main package cannot reference static resources in subpackages. Large poster images and check-in paper styles can go in subpackages; brand fonts that must be visible immediately upon opening must be in the main package.
Configuring preloadRule in pages.json for the homepage, detail page, and list page to pre-download the media subpackage only solves the problem for images and the check-in page, not for the first-screen title.
8. Error Reference Table
Check against this table during real-device debugging to save a lot of detours.
| Error / Symptom | Common Cause | How to Handle |
|---|---|---|
url scheme is invalid |
source used wxfile://, http://usr, or intra-package path |
Change to Data URL or https |
network error (loadFontFace) |
Data URL paired with scopes: ['native']; or https 404 / not a legal domain |
UI uses only webview; confirm https is accessible in a browser first |
loadFontFace:fail with no details |
family conflict, empty source, truncated base64 | Log the family and the prefix of the source |
| Font works in tools, not on real device | Tools allowed local paths | Treat the real device as the source of truth |
| Console success, but page still shows Heiti | font-weight: 700 fallback; or CSS family doesn't match registered name |
Change to 400; unify the name table |
| Title has font, but some body text is Heiti | Subset missing characters | Supplement subset or wait for full https package to overwrite |
| Poster is Heiti, page has custom font | Only did loadFontFace, no canvas.loadFont | Use USER_DATA + Canvas 2D |
| Poster occasionally reverts to Heiti | loadFont called before setting canvas dimensions; or no delay | Set width/height first, then loadFont, then wait 100ms+ |
| English text shows as boxes | Handwriting font has no Latin characters | Switch family based on whether text contains Chinese characters |
| Search box ghosting | <text> overlaid on <input> |
Remove the overlay |
| Main package exceeds 2MB | Full ttf/woff entered the main package | Subset + cloud full package |
| https font 404 | File not deployed to /public/fonts, or Nginx didn't configure woff MIME |
Direct browser access to the URL should trigger a download |
Legal domains to configure in the mini program backend:
downloadFilelegal domain: the full font's https domain- If using downloadFile to save to USER_DATA, it's the same domain
The Content-Type for woff is recommended to be font/woff or application/font-woff. If configured as text/html, loadFontFace will also fail.
9. A Minimal Runnable Checklist
If you just want to swap a set of title fonts in your own mini program, follow this:
- Use fontTools to subset based on actual copy, output woff, change family to
MyTitleFont. - Convert to base64, put in main package JSON, keep size within main package budget.
- In
App.onLaunch, callloadFontFace,sourceuses Data URL,scopes: ['webview'],global: true. - CSS:
font-family: MyTitleFont;andfont-weight: 400. - Preview on a real device. Only after success, consider the full https package.
- If there are posters:
writeFilethe same woff toUSER_DATA, usecanvas.loadFont(path)in Canvas 2D,ctx.fontuses only the returned family. - Don't put font JSON in a subpackage and async require it; don't use
native+ Data URL; don't overlay<text>on<input>.
For full Chinese reading and poster export, add:
- Server hosts the full woff, downloadFile → verify size → https
loadFontFaceoverwrite. - Separate Chinese and English text flows.
- iconfont must have
font-family: iconfont !important, otherwise the global serif will eat the icons.
10. Why JuShi Is Worth the Font Cost
If a sentence product uses the system Heiti, the detail page and poster instantly become "just another copywriting station." Serif and handwriting fonts are the reading atmosphere itself, not decoration.
The cost is: the loading pipeline becomes longer, and all failure paths must be fully covered. JuShi's fallbacks are:
- Subset failure: the page still opens, just with the system font
- Cloud failure: continues using the subset, doesn't interrupt reading
- Poster failure: falls back to exporting with the system font, not a white screen
- native failure: ignored, doesn't affect the already successful page font
The business logic could be done in a week. Fonts took so long because they simultaneously hit file size, protocol, dual-end APIs, CSS fallback, and subpackaging—five things, none of which are obvious in the developer tools, but which explode together on a real device.
In Conclusion
WeChat Mini Program custom fonts are not "drop a ttf into static and write a line of CSS." There are only two paths to stable text rendering:
- Page: https or Data URL →
loadFontFace(webview) - Poster: Local file → Canvas 2D
loadFont
Any other approach is mostly an illusion given to you by the developer tools.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
why download and check the size first when loading fonts
can be ignored, this is because the local font package hasn't been uploaded to the server yet, so it's 404