Skip to content

About

Mix your favourite CJK font and monospaced font, 1:2

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

CJKonospace

English | 简体中文 | 繁體中文 | 日本語 | 한국어

CJKonospace

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/

Quick start

pnpm install
pnpm dev      # dev server
pnpm build    # production build (dist/)

How it works

  • 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.py runs 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.

Command line

uv run wasm/merge_font.py mono.ttf cjk.ttf out.ttf params.json

mono.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 brotli dependency for this).
  • TTC collections are supported: pick a face with ttcIndex (default 0).
  • Variable-font inputs are pinned to a static instance via variations.mono / variations.cjk (fontTools varLib.instancer); an empty location pins every axis at its default.
  • With mergeWght, the shared wght axis is merged into a variable output instead of pinning. Both inputs must be variable glyf/gvar fonts with a wght axis (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 and rvrn swaps, and pairs the two fonts' masters through weightMap anchors.
  • The CJK input can be subset with cjk.subset.unicodes (fontTools subset, applied before instancing/merging).
  • Output outlines are always TrueType (glyf); set format: "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 (hhea ascent/descent, OS/2 typo + win metrics) are recomputed from both fonts to cover every glyph, so tall CJK glyphs are not clipped. hhea.lineGap and OS/2.sTypoLineGap are forced to 0, 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; a gasp table 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.
  • styleName is 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.

License

GPL-3.0-only

About

Mix your favourite CJK font and monospaced font, 1:2

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Contributors

Languages