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, gradients, outlines, shadows, 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.
- Font directory scanning via
font_dir=constructor arg orscan_font_dir()-- point to a folder of fonts and skip manualFontConfigwiring. Fonts are loaded in alphabetical order by filename, so the first file becomes the primary font. - 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.
- Gradient fills on text, outlines, and shadows via dash-separated color strings (e.g.
"red-blue","#FF0000-#00FF00") or the"rainbow"preset. Gradients are slightly diagonal so multi-line text gets natural color variation per line. - Text outlines (strokes) with configurable thickness and color, including gradient outlines.
- Drop shadows with configurable color and offset. Shadow shape includes the outline when
stroke_width > 0. Supports gradient shadow colors. - 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.
- Anchor points (
anchor=) onFontManager.draw(): choose which corner, edge midpoint, or centre of the text block lands atpositionusing PIL-style two-character codes ("lt","mm","rb", etc.). - 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 |
|
Gradient fills, gradient outlines, gradient shadows |
All effects combined - gradient + outline + shadow |
Text outlines on Latin, Arabic, and mixed scripts |
Drop shadows with emoji silhouettes |
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"),
]
)
# Or point to a directory and let FontStack discover all fonts
# (loaded alphabetically by filename - first file = primary font):
# manager = FontManager(font_dir="fonts/")
from PIL import Image
img = Image.new("RGBA", (800, 100), "white")
manager.draw(
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
# Option 1: explicit font stack (full control over fallback order)
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"),
]
)
# Option 2: scan a directory (auto-discovers all .ttf/.otf/.ttc/.otc files)
# Fonts are loaded in alphabetical order by filename, so the first file
# becomes the primary font and later files act as fallbacks.
manager = FontManager(font_dir="fonts/")
from PIL import Image
img = Image.new("RGBA", (1000, 200), "white")
w, h = manager.draw(
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")
draw_text
Returns a new PIL.Image.Image cropped tightly to the rendered text, no canvas management needed.
from fontstack import FontConfig, draw_text
img = draw_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(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(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(img, long_text, position=(0, 0), size=32,
mode="fit", max_width=400, max_height=120, min_size=10)
Anchor points
anchor controls which point of the text block is placed at position.
Useful for centering labels, pinning captions to corners, or aligning text
to UI grid lines without manual offset math.
lt ── mt ── rt
│ │
lm mm rm
│ │
lb ── mb ── rb
# Centre text on a specific pixel (badge, map pin, etc.)
manager.draw(img, "Hello", position=(400, 200), size=48, anchor="mm")
# Bottom-right: text ends exactly at a corner
manager.draw(img, "caption", position=(img.width - 16, img.height - 16),
size=20, anchor="rb")
# Top-center: heading centered at the top edge of a box
manager.draw(img, "Title", position=(box_cx, box_top), size=32, anchor="mt")
Text effects
# Gradient fill (diagonal by default so each line gets different hues)
manager.draw(img, "Gradient!", position=(20, 20), size=64,
fill="red-gold-orange")
# Pure left-to-right gradient (no diagonal)
manager.draw(img, "Gradient!", position=(20, 20), size=64,
fill="red-gold-orange", gradient_angle=0.0)
# Outline with solid stroke color
manager.draw(img, "Outlined", position=(20, 120), size=64,
fill="white", stroke_width=3, stroke_fill="black")
# Drop shadow (shadow includes the outline shape when stroke_width > 0)
manager.draw(img, "Shadow", position=(20, 220), size=64,
fill="white", shadow_color=(0, 0, 0, 120),
shadow_offset=(4, 4))
# Everything at once: gradient fill + gradient outline + gradient shadow
manager.draw(img, "All Effects!", position=(20, 320), size=64,
fill="red-orange-gold",
stroke_width=3, stroke_fill="blue-cyan",
shadow_color="gray-darkgray", shadow_offset=(3, 3))
# Rainbow preset
manager.draw(img, "Rainbow!", position=(20, 420), size=64,
fill="rainbow")
Batch rendering with a shared manager
Reusing a FontManager across many draw_text calls avoids re-parsing cmaps for every image.
from fontstack import FontConfig, FontManager, draw_text
mgr = FontManager(default_stack=[FontConfig(path="fonts/NotoSans[wdth,wght].ttf")])
labels = ["First", "Second", "Third", ...]
images = [draw_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.
API Reference
FontManager(default_stack=None, *, font_dir=None, max_cache=30)
Create with an explicit default_stack or a font_dir path (mutually exclusive). When font_dir is given, all .ttf, .otf, .ttc, .otc files in the directory are scanned and sorted alphabetically by filename (case-insensitive). The first file in that order becomes the primary font; the rest serve as fallbacks.
| Method / Property | Returns | Description |
|---|---|---|
draw(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 (auto-built from font_dir if used). |
font_dir |
str | Path | None |
The font directory passed at construction, or None. |
draw 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. |
anchor |
"lt" … "rb" |
"lt" |
Which point of the text block lands at position. Two-char PIL-style code: horizontal (l/m/r) + vertical (t/m/b). |
line_spacing |
float |
1.2 |
Line-height multiplier (1.0 = tight, 1.5 = loose). |
fill |
FillType |
"black" |
Text color: color name, RGB/RGBA tuple, palette integer, or gradient string. |
stroke_width |
int |
0 |
Outline thickness in pixels around each glyph. |
stroke_fill |
FillType | None |
None |
Outline color. Same value types as fill, including gradients. |
shadow_color |
FillType | None |
None |
Drop-shadow color. Same value types as fill, including gradients. |
shadow_offset |
tuple[int, int] |
(2, 2) |
Shadow pixel offset (x, y). |
gradient_angle |
float |
15.0 |
Gradient direction in degrees (0 = left-to-right, 15 = slight diagonal). |
font_stack |
list[FontConfig] | None |
None |
Per-call font stack override; falls back to default_stack. |
emoji_source |
BaseSource |
Twemoji |
Pilmoji emoji image source. |
draw_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. Also accepts font_dir= as an alternative to font_stack.
scan_font_dir(font_dir, *, recursive=False) -> list[FontConfig]
Scan a directory for font files (.ttf, .otf, .ttc, .otc) and return a list of FontConfig entries sorted alphabetically by filename (case-insensitive). The first entry becomes the primary font when passed to FontManager. TTC/OTC collections produce one entry per member font. Useful for inspecting what font_dir= will discover, or for building a custom stack from a directory listing.
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.3.5.tar.gz.
File metadata
- Download URL: fontstack-0.3.5.tar.gz
- Upload date:
- Size: 77.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a146b0cf14d53d314525786c9b2944513e83be649406da30fa4d90eff6f3d019
|
|
| MD5 |
fcc9060c8417332e1c0dd112ae7c5c87
|
|
| BLAKE2b-256 |
7001ecc5b0182daac1962b7c5db64efd26a667492996079bfa74b5dfe7a69738
|
Provenance
The following attestation bundles were made for fontstack-0.3.5.tar.gz:
Publisher:
release.yml on Kanin/FontStack
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fontstack-0.3.5.tar.gz -
Subject digest:
a146b0cf14d53d314525786c9b2944513e83be649406da30fa4d90eff6f3d019 - Sigstore transparency entry: 1434057364
- Sigstore integration time:
-
Permalink:
Kanin/FontStack@81cb0a06b690916dcc7e8fc2c9f60109b5b9a1f9 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Kanin
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@81cb0a06b690916dcc7e8fc2c9f60109b5b9a1f9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file fontstack-0.3.5-py3-none-any.whl.
File metadata
- Download URL: fontstack-0.3.5-py3-none-any.whl
- Upload date:
- Size: 58.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
47c9a3a7787ccb802dc1514063bce9297468fa65853dc17308998b1fbabb06d5
|
|
| MD5 |
0860cb409807e710c157c63ab9d8a666
|
|
| BLAKE2b-256 |
ae35be58a8a78e3a48807087a667720d6e11e4f4807912195f57042e9ccfd0fe
|
Provenance
The following attestation bundles were made for fontstack-0.3.5-py3-none-any.whl:
Publisher:
release.yml on Kanin/FontStack
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fontstack-0.3.5-py3-none-any.whl -
Subject digest:
47c9a3a7787ccb802dc1514063bce9297468fa65853dc17308998b1fbabb06d5 - Sigstore transparency entry: 1434057573
- Sigstore integration time:
-
Permalink:
Kanin/FontStack@81cb0a06b690916dcc7e8fc2c9f60109b5b9a1f9 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Kanin
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@81cb0a06b690916dcc7e8fc2c9f60109b5b9a1f9 -
Trigger Event:
push
-
Statement type: