English | 简体中文 | 繁體中文 | 日本語 | 한국어
A CJK × Monospace blending workbench: feed in two fonts (one for CJK, one for monospace), preview them mixed live in the browser, and tune character width vs. glyph width until CJK : mono hits a visually balanced 1 : 2 ratio (Maple Mono style).
Live app: https://inchei.github.io/CJKonospace/
pnpm install
pnpm dev # dev server
pnpm build # production build (dist/)- Parsing & preview — opentype.js parses both fonts and the Canvas renderer lays out the mixed lines; harfbuzzjs (WASM) runs real GSUB shaping so mono ligatures show up in the preview.
- WOFF2 I/O — WOFF2 input is decompressed and WOFF2 output is compressed with woff2-encoder (WASM).
- Generation — the same
wasm/merge_font.pyruns in the browser inside pyodide (WASM), with fontTools loaded from a CDN, so no server round-trip is needed.
Three WASM modules, all lazy-loaded (only fetched when first used): harfbuzzjs (~0.4 MB) for shaping, woff2-encoder (~0.27 MB) for WOFF2 input, and pyodide + fontTools (~10 MB) for font generation. Python at runtime has no dependency on the WASM path — see the command line section below.
uv run wasm/merge_font.py mono.ttf cjk.ttf out.ttf params.jsonmono.ttf is the base font (its glyph IDs, GSUB/GPOS/GDEF and Latin coverage are kept), cjk.ttf supplies the glyphs to append. params.json:
{
"fs": 48,
"lock2to1": true,
"familyName": "MyMono",
"styleName": "Regular",
"lineHeight": 1.0,
"format": "ttf",
"variations": { "mono": {}, "cjk": {} },
"mono": { "advMul": 1, "gsx": 1, "gsy": 1, "baseline": 0, "ttcIndex": 0 },
"cjk": {
"advMul": 1,
"gsx": 1,
"gsy": 1,
"baseline": 0,
"ttcIndex": 0,
"subset": { "unicodes": [19968, 19969] }
}
}| Key | Meaning |
|---|---|
fs |
reference font size in px; only used to convert baseline offsets from px to font units |
lock2to1 |
lock CJK advance to 2 × mono advance (the whole point of the tool) |
lineHeight |
multiplier for the recomputed ascent/descent (default 1.0; gaps are always 0) |
format |
output packaging: ttf (default) or woff2 |
mono / cjk.advMul |
advance multiplier |
mono / cjk.gsx, gsy |
outline scale (glyph width / height), advance untouched |
mono / cjk.baseline |
baseline offset in px (see fs) |
mono / cjk.ttcIndex |
face index when the input is a .ttc collection (default 0) |
variations.mono / .cjk |
variable-font instance location (axis tag → value), e.g. { "wght": 700 } |
mergeWght |
merge the shared wght axis into a variable output instead of pinning (see below) |
weightMap |
[[mono, cjk], ...] wght anchors (user values) pairing the two fonts; default aligns min/default/max |
axisRange |
{"min": ..., "max": ...} narrowed output wght range (default: full mono range) |
cjk.subset |
keep only these codepoints in the CJK input, e.g. { "unicodes": [19968] } (omit = full) |
Notes:
- Inputs may be TTF, OTF or WOFF2 (WOFF2 is decompressed first; the CLI declares the
brotlidependency for this). - TTC collections are supported: pick a face with
ttcIndex(default0). - Variable-font inputs are pinned to a static instance via
variations.mono/variations.cjk(fontToolsvarLib.instancer); an empty location pins every axis at its default. - With
mergeWght, the sharedwghtaxis is merged into a variable output instead of pinning. Both inputs must be variableglyf/gvarfonts with awghtaxis (otherwise falls back to static with a warning); the output spans the mono range, keeps the mono base's avar weight curve, its named instances andrvrnswaps, and pairs the two fonts' masters throughweightMapanchors. - The CJK input can be subset with
cjk.subset.unicodes(fontToolssubset, applied before instancing/merging). - Output outlines are always TrueType (
glyf); setformat: "woff2"to get a WOFF2 package of the same font. A CFF/OTF mono base is converted (cu2qu; CFF hinting is dropped). - The tool does not touch hinting, so scaled CJK glyphs keep their original instructions.
- Vertical metrics (
hheaascent/descent,OS/2typo + win metrics) are recomputed from both fonts to cover every glyph, so tall CJK glyphs are not clipped.hhea.lineGapandOS/2.sTypoLineGapare forced to0, since terminals interpret a positive gap inconsistently. - The result is flagged monospace (
post.isFixedPitch = 1,OS/2.panose.bProportion = 9;OS/2.xAvgCharWidth= half-width; agasptable is added when missing) only when the mono base's printable-ASCII glyphs actually share one advance (CJK is full-width 2x by design and is not part of this check; a base with no ASCII glyph also counts as not monospaced). A proportional base keeps its own flags. styleNameis freely typed and sets the output subfamily (name IDs 2/17/22) and style metadata (OS/2.usWeightClass,OS/2.usWidthClass,fsSelection,head.macStyle), using the keyword mapping from the OpenType name examples. It can be left empty to inherit the mono base's subfamily and keep its style metadata unchanged.
GPL-3.0-only