Unicode text rendering with automatic per-character font fallback, BiDi support, and emoji
Project description
FontStack
Unicode text rendering for Pillow with automatic per-character font fallback, variable fonts, BiDi/RTL, and emoji.
Pillow's built-in text rendering uses a single font, so any character not covered by that font shows up as a blank box ("tofu"). FontStack fixes this by walking an ordered list of fonts per character, the same fallback strategy browsers and operating systems use. It also handles right-to-left scripts, Arabic contextual shaping, variable fonts, TrueType Collections, and emoji.
Features
- Per-character font fallback using fonttools for accurate cmap parsing across TTF, OTF, and collection formats.
- RTL/BiDi support via
python-bidifor Unicode BiDi reordering. Arabic text is reshaped witharabic-reshaperbefore rendering so letters connect correctly under Pillow's BASIC layout engine. - Emoji rendered via Pilmoji / Twemoji with correct baseline alignment across mixed font and emoji runs.
- Variable font support: set axes by integer value (
weight=700setswght) or by named style (weight="Bold"). TypedVariationAxesfor IDE autocomplete on standard axes. - TrueType/OpenType Collection support (
.ttc/.otc) viattc_indexonFontConfig. - Two rendering modes:
"wrap"breaks text across lines at a max width;"scale"shrinks the font to fit, truncating with…as a last resort. - Fit mode (
"fit") combines both: wraps atmax_width, then shrinks the font until the block fits withinmax_height, then truncates the last visible line with…if necessary.min_sizesets the floor for both scale and fit modes. - Left, center, and right alignment within the text block.
- LRU caching on both font objects and cmap data; repeated renders with the same stack/size/weight are essentially free.
- Fully typed:
Literalonmodeandalign,@overloadsignatures that surfacemin_sizeonly whenmode="scale"ormode="fit"andmax_heightonly whenmode="fit", PEP 561py.typedmarker.
Gallery
Nine languages, one stack |
Chinese · Japanese · Korean |
Devanagari · Hebrew · Bengali · Thai |
Variable font weight axis (wght 100–900) |
Mixed Arabic + Latin - BiDi reordering applied automatically |
|
Symbols · Math alphanumerics · Box drawing · Arrows |
|
Fit mode - wrap → shrink → truncate, all four strips share the same bounding box |
|
Installation
pip install fontstack
Note: FontStack does not bundle fonts. See Recommended Font Stack below for a curated set of free Noto fonts that provide near-complete Unicode coverage.
Quick Start
from fontstack import FontConfig, FontManager
manager = FontManager(
default_stack=[
FontConfig(path="fonts/NotoSans[wdth,wght].ttf"),
FontConfig(path="fonts/NotoSansArabic[wdth,wght].ttf"),
]
)
from PIL import Image
img = Image.new("RGBA", (800, 100), "white")
manager.draw_text_smart(
image=img,
text="Hello مرحبا",
position=(20, 20),
size=48,
weight=700,
fill=(20, 20, 20),
)
img.save("output.png")
Usage
FontManager
from fontstack import FontConfig, FontManager, VariationAxes
manager = FontManager(
default_stack=[
# Primary font: Noto Sans variable (Latin, Cyrillic, Greek)
FontConfig(path="fonts/NotoSans[wdth,wght].ttf"),
# Fallback 1: Noto Sans Arabic (Arabic, Persian, Urdu)
FontConfig(path="fonts/NotoSansArabic[wdth,wght].ttf"),
# Fallback 2: Noto Sans SC/JP/KR (Simplified Chinese / Japanese / Korean)
FontConfig(path="fonts/NotoSansSC[wght].ttf"),
FontConfig(path="fonts/NotoSansJP[wght].ttf"),
FontConfig(path="fonts/NotoSansKR[wght].ttf"),
]
)
from PIL import Image
img = Image.new("RGBA", (1000, 200), "white")
w, h = manager.draw_text_smart(
image=img,
text="Hello 世界 مرحبا 🌍",
position=(20, 40),
size=48,
weight=700,
mode="wrap",
max_width=960,
align="center",
fill=(30, 30, 30),
)
print(f"Rendered {w}×{h} px")
img.save("output.png")
render_text
Returns a new PIL.Image.Image cropped tightly to the rendered text, no canvas management needed.
from fontstack import FontConfig, render_text
img = render_text(
text="Hello 世界 مرحبا 🌍",
font_stack=[
FontConfig(path="fonts/NotoSans[wdth,wght].ttf"),
FontConfig(path="fonts/NotoSansArabic[wdth,wght].ttf"),
],
size=48,
weight=700,
fill=(20, 20, 20),
background="white",
padding=16,
)
img.save("hello.png")
Variable font axes
from fontstack import FontConfig, VariationAxes
# Narrow, light weight, slightly slanted
FontConfig(
path="fonts/NotoSans[wdth,wght].ttf",
axes=VariationAxes(wght=300.0, wdth=75.0, slnt=-10.0),
)
Standard axes in VariationAxes: wght (weight, 100–900), wdth (width, 50–200), ital (italic, 0–1), slnt (slant, degrees), opsz (optical size).
Rendering modes
# "wrap" - word-wrap at max_width, font size unchanged
manager.draw_text_smart(img, long_text, position=(0, 0), size=32,
mode="wrap", max_width=400)
# "scale" - shrink font until the full text fits on a single line;
# truncates with "…" if the text is still too wide at min_size
manager.draw_text_smart(img, long_text, position=(0, 0), size=32,
mode="scale", max_width=400, min_size=10)
# "fit" - wrap first, then shrink until the block fits within max_width × max_height;
# if the block still overflows at min_size the last visible line is
# truncated with "…"
manager.draw_text_smart(img, long_text, position=(0, 0), size=32,
mode="fit", max_width=400, max_height=120, min_size=10)
Batch rendering with a shared manager
Reusing a FontManager across many render_text calls avoids re-parsing cmaps for every image.
from fontstack import FontConfig, FontManager, render_text
mgr = FontManager(default_stack=[FontConfig(path="fonts/NotoSans[wdth,wght].ttf")])
labels = ["First", "Second", "Third", ...]
images = [render_text(label, font_stack=[], manager=mgr, size=32) for label in labels]
Recommended Font Stack
All fonts below are from Google's Noto family, licensed under the SIL Open Font License 1.1 (free for commercial use).
| # | Font | Scripts covered | Size | Download |
|---|---|---|---|---|
| 1 | Noto Sans [wdth,wght].ttf |
Latin, Cyrillic, Greek, Latin Extended | ~1.1 MB | Google Fonts |
| 2 | Noto Sans Arabic [wdth,wght].ttf |
Arabic, Persian, Urdu | ~840 KB | Google Fonts |
| 3 | Noto Sans SC [wght].ttf |
Simplified Chinese | ~17 MB | Google Fonts |
| 4 | Noto Sans JP [wght].ttf |
Japanese | ~9.4 MB | Google Fonts |
| 5 | Noto Sans KR [wght].ttf |
Korean | ~10 MB | Google Fonts |
| 6 | Noto Sans Devanagari [wdth,wght].ttf |
Hindi, Sanskrit, Marathi, Nepali | ~632 KB | Google Fonts |
| 7 | Noto Sans Hebrew [wdth,wght].ttf |
Hebrew, Yiddish | ~110 KB | Google Fonts |
| 8 | Noto Sans Bengali [wdth,wght].ttf |
Bengali, Assamese | ~454 KB | Google Fonts |
| 9 | Noto Sans Thai [wdth,wght].ttf |
Thai | ~214 KB | Google Fonts |
Emoji are handled by Pilmoji/Twemoji automatically, no emoji font needed in the stack.
from fontstack import FontConfig, FontManager
FONT_DIR = "fonts"
manager = FontManager(
default_stack=[
FontConfig(path=f"{FONT_DIR}/NotoSans[wdth,wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansArabic[wdth,wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansSC[wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansJP[wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansKR[wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansDevanagari[wdth,wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansHebrew[wdth,wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansBengali[wdth,wght].ttf"),
FontConfig(path=f"{FONT_DIR}/NotoSansThai[wdth,wght].ttf"),
]
)
API Reference
FontManager(default_stack, max_cache=30)
| Method | Returns | Description |
|---|---|---|
draw_text_smart(image, text, position, ...) |
tuple[int, int] |
Draw text onto an existing image in-place. Returns (width, height) of the rendered bounding box. |
get_font_chain(size, weight, custom_stack) |
list[FreeTypeFont] |
Return loaded font objects for the given size/weight (LRU-cached). |
default_stack |
list[FontConfig] |
Read-only copy of the stack passed at construction. |
draw_text_smart key parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
size |
int |
40 |
Starting font size in points. |
weight |
int | str |
400 |
Font weight axis value or named style string (e.g. 700 or "Bold"). |
mode |
"wrap" | "scale" | "fit" |
"wrap" |
Rendering mode. |
max_width |
int | None |
None |
Maximum line width in pixels. |
max_height |
int | None |
None |
Maximum block height in pixels ("fit" mode only). |
min_size |
int |
12 |
Minimum font size for "scale" and "fit" modes. |
align |
"left" | "center" | "right" |
"left" |
Horizontal alignment within the text block. |
line_spacing |
float |
1.2 |
Line-height multiplier (1.0 = tight, 1.5 = loose). |
fill |
FillType |
"black" |
Text color: color name, RGB/RGBA tuple, or palette integer. |
font_stack |
list[FontConfig] | None |
None |
Per-call font stack override; falls back to default_stack. |
emoji_source |
BaseSource |
Twemoji |
Pilmoji emoji image source. |
render_text(text, font_stack, ...) -> Image.Image
Convenience wrapper: creates a FontManager (or reuses one via manager=), renders text, and returns a new RGBA image cropped to the result with optional padding and background.
FontConfig
| Field | Type | Default | Description |
|---|---|---|---|
path |
str |
required | Path to TTF, OTF, TTC, or OTC file. |
axes |
VariationAxes | None |
None |
Default variable font axis values. |
ttc_index |
int |
0 |
Index within a TTC/OTC collection. |
VariationAxes (TypedDict, all optional)
wght · wdth · ital · slnt · opsz
Requirements
- Python 3.11+
- Pillow ≥ 12.2
- pilmoji ≥ 2.0.5
- fonttools ≥ 4.62
- python-bidi ≥ 0.6.7
- arabic-reshaper ≥ 3.0.0
License
MIT © 2026 Kanin
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fontstack-0.1.1.tar.gz.
File metadata
- Download URL: fontstack-0.1.1.tar.gz
- Upload date:
- Size: 38.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
105e66064408ddaa3ef310139a1bb1d9bc98005685bb5e5aa2045d2d243edfda
|
|
| MD5 |
67ccc065932c7e24c50a3002baced28e
|
|
| BLAKE2b-256 |
d2fb5a355502cb20537a5678b815f91e599eb5d6184397e1618837c31e8226e7
|
File details
Details for the file fontstack-0.1.1-py3-none-any.whl.
File metadata
- Download URL: fontstack-0.1.1-py3-none-any.whl
- Upload date:
- Size: 21.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5570f9677bb07aa70a10b0e7bce6b9be258b611b8dea3e60d5364b50e5e0ff6
|
|
| MD5 |
e1bc7b0d244bfcdcc8df46886fa5fc0d
|
|
| BLAKE2b-256 |
aa12ba831dbf8ff744ff116a28795032f961e0e76156888e40ca8f0ed30ba05d
|