fontstack
Unicode text rendering with automatic per-character font fallback, BiDi support, and emoji.
API Reference
FontStack - Unicode text rendering with automatic per-character font fallback.
- class fontstack.FontConfig(path: str, axes: VariationAxes | None = None, ttc_index: int = 0)
Configuration for a single font entry in a fallback stack.
- Parameters:
path (str) – Absolute or relative path to a TTF, OTF, TTC, or OTC font file.
axes (VariationAxes or None, optional) – Default variable-font axis values applied when loading this font. The
weightargument passed to rendering methods always overrides thewghtaxis specifically. SeeVariationAxesfor the full list of standard axes. Defaults toNone.ttc_index (int, optional) – Zero-based index of the desired font within a TrueType Collection (
.ttc/.otc) file. Ignored for non-collection files. Defaults to0.
Examples
Basic font entry:
FontConfig(path="fonts/NotoSans[wdth,wght].ttf")
Variable font with custom default axes:
FontConfig( path="fonts/NotoSans[wdth,wght].ttf", axes=VariationAxes(wght=300.0, wdth=75.0), )
Font from a TrueType Collection:
FontConfig(path="fonts/NotoSansCJK.ttc", ttc_index=2)
- axes: VariationAxes | None = None
- path: str
- ttc_index: int = 0
- class fontstack.FontManager(default_stack: list[FontConfig], max_cache: int = 30)
Manages a prioritized fallback stack of fonts and renders Unicode text onto PIL images, including emoji, Arabic/RTL script, and CJK.
How fallback works
Each character in a string is assigned to the first font in the stack whose cmap contains that codepoint. Emoji are always pinned to the primary (index-0) font because Pilmoji intercepts and rasterises them independently; routing them to a fallback font would corrupt the baseline correction applied during rendering.
TTC support
Font collection files (.ttc, .otc) are supported via the
ttc_indexkey inFontConfig. Each TTC entry is treated as a normal font; the manager reads the cmap of the specified member and loads it through Pillow using the same index.BiDi / RTL support
All text is automatically processed before rendering. Arabic is reshaped with
arabic-reshaperto produce the correct contextual letter forms (initial, medial, final, isolated), then passed throughpython-bidifor Unicode BiDi reordering. This makes Arabic render correctly under Pillow’s BASIC layout engine.Caching
Font objects and cmap data are LRU-cached internally. Repeated calls with the same
(stack, size, weight)triple are essentially free.- param default_stack:
Ordered list of
FontConfigentries. The first item is the primary font; subsequent entries are tried in order when the primary lacks a glyph for a given character.- param max_cache:
Maximum number of
(stack, size, weight)entries to keep in the font-object LRU cache. Older entries are evicted when the cache is full. Defaults to30.
Example
manager = FontManager( default_stack=[ FontConfig(path="fonts/NotoSans.ttf"), FontConfig(path="fonts/NotoSansCJK.ttc", ttc_index=0), FontConfig(path="fonts/NotoSansArabic.ttf"), ] ) img = Image.new("RGBA", (800, 100), "white") manager.draw(img, "Hello مرحبا 世界", position=(10, 20), size=40)
- property default_stack: list[FontConfig]
The fallback font stack supplied at construction time (read-only copy).
- draw(image: ~PIL.Image.Image, text: str, position: tuple[int, int], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['wrap'] = 'wrap', max_width: int | None = None, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', font_stack: list[~fontstack._core.FontConfig] | None = None, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>) tuple[int, int]
- draw(image: ~PIL.Image.Image, text: str, position: tuple[int, int], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['scale'] = 'wrap', max_width: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', font_stack: list[~fontstack._core.FontConfig] | None = None, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>) tuple[int, int]
- draw(image: ~PIL.Image.Image, text: str, position: tuple[int, int], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['fit'] = 'wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', font_stack: list[~fontstack._core.FontConfig] | None = None, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>) tuple[int, int]
- draw(image: ~PIL.Image.Image, text: str, position: tuple[int, int], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['wrap', 'scale', 'fit'] = 'wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', font_stack: list[~fontstack._core.FontConfig] | None = None, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>) tuple[int, int]
Draw text onto image with automatic per-character font fallback, BiDi/Arabic support, and emoji rendering, then return the pixel dimensions of the rendered bounding box.
Before any measuring or drawing, text is reshaped and BiDi-reordered by
_prepare_bidi(), making mixed-direction strings render correctly in Pillow’s left-to-right model. Text is rendered into a transparent RGBA overlay first, then composited onto image, which prevents anti-aliasing artifacts on non-white backgrounds.- Parameters:
image – The target
Image. Modified in place."RGBA"mode is recommended; other modes composite correctly but may lose transparency.text – The string to render. May contain any Unicode codepoints, emoji sequences, or Arabic/Hebrew/mixed-direction content. BiDi reordering and reshaping are applied automatically.
position –
(x, y)pixel coordinate of the visual top-left corner of the text block. Corrects for the font’s internal bounding-box offset so this maps to visible pixels, not FreeType’s internal origin.size – Initial font size in points. In
"scale"mode this may be reduced; in"wrap"mode it is used as-is for all lines. Defaults to40.weight – Font weight. Integer (e.g.
700) sets thewghtvariable axis; a string (e.g."Bold") callsset_variation_by_name(). Static fonts ignore this silently. Defaults to400.mode –
Rendering mode. One of:
"wrap"Word-wrap the text at max_width. Lines are spaced at
size * line_spacingpixels. A single word wider than max_width will overflow without truncation. When max_width isNone, the text renders as a single line with no wrapping."scale"Shrink the font in 2pt steps down to min_size until the text fits within max_width. If it still overflows, characters are truncated with an ellipsis. When max_width is
None, the text renders at full size with no scaling."fit"Word-wrap at max_width first (like
"wrap"), then shrink the font in 2pt steps until the wrapped text block fits within max_height. If the block still overflows at min_size, lines that do not fit are dropped and the last kept line is truncated with an ellipsis. When max_height isNone, this mode behaves identically to"wrap".
Defaults to
"wrap".max_width – Maximum pixel width for the text area.
Nonemeans no constraint. Defaults toNone.max_height – Maximum pixel height for the text block. Only used in
"fit"mode.Nonemeans no height constraint. Defaults toNone.min_size – Minimum font size (points) used as the floor in
"scale"and"fit"modes. Ignored in"wrap"mode. Defaults to12.align – Horizontal alignment of lines within the text block. One of
"left","center", or"right". Only affects multi-line"wrap"output; single-line text always starts at position. Defaults to"left".line_spacing – Line height multiplier relative to size.
1.2means each line’s top issize * 1.2pixels below the previous line’s top. Increase for scripts with tall diacritics (e.g. Arabic tashkeel). Defaults to1.2.fill – Text color. Accepts a Pillow color name (
"red"), an RGB tuple ((255, 0, 0)), an RGBA tuple ((255, 0, 0, 128)for 50% opacity), or an integer for palette/grayscale images. Defaults to"black".font_stack – Override the instance’s
default_stackfor this call only.emoji_source – Pilmoji emoji image source class (not instance). Defaults to
Twemoji.
- Returns:
(width, height)of the tightest bounding box around the actually-drawn pixels, ignoring all font internal padding. Returns(0, 0)if nothing was drawn (e.g. empty string).- Return type:
tuple[int, int]
- Raises:
ValueError – If mode is not
"wrap","scale", or"fit".ValueError – If align is not
"left","center", or"right".
- get_font_chain(size: int, weight: int | str = 400, custom_stack: list[FontConfig] | None = None) list[FreeTypeFont]
Return a list of loaded
FreeTypeFontobjects for each entry in the active stack at the given size and weight. The result is LRU-cached so subsequent calls with the same arguments are essentially free.- Parameters:
size – Point size passed to
PIL.ImageFont.truetype().weight – Font weight. Can be an integer (e.g.
400,700) which sets thewghtvariation axis, or a named style string (e.g."Bold") passed toset_variation_by_name(). For static (non-variable) fonts the weight parameter is silently ignored.custom_stack – Override the instance’s
default_stackfor this call only. Useful when rendering a single text element with a bespoke font combination without affecting the manager’s default behaviour.
- Returns:
One loaded font object per stack entry, in the same order as the stack.
- Return type:
list[ImageFont.FreeTypeFont]
- class fontstack.VariationAxes
Standard OpenType variation axes for variable fonts.
All fields are optional (
total=False). Non-standard axes such as"YTLC"cannot be expressed in this TypedDict but will still pass through to Pillow at runtime.- wght
Weight axis. Typical range 100–900.
400is Regular,700is Bold.- Type:
float
- wdth
Width axis. Typical range 50–200.
100is normal width.- Type:
float
- ital
Italic axis.
0is upright,1is italic.- Type:
float
- slnt
Slant axis in degrees. Negative values lean right (e.g.
-12).- Type:
float
- opsz
Optical size axis. Should match the intended display size in points (e.g.
12,24,72).- Type:
float
- ital: float
- opsz: float
- slnt: float
- wdth: float
- wght: float
- fontstack.draw_text(text: str, font_stack: list[~fontstack._core.FontConfig], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['wrap'] = 'wrap', max_width: int | None = None, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int] = (0, 0, 0, 0), padding: int = 0, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: ~fontstack._core.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack._core.FontConfig], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['scale'] = 'wrap', max_width: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int] = (0, 0, 0, 0), padding: int = 0, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: ~fontstack._core.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack._core.FontConfig], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['fit'] = 'wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int] = (0, 0, 0, 0), padding: int = 0, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: ~fontstack._core.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack._core.FontConfig], *, size: int = 40, weight: int | str = 400, mode: ~typing.Literal['wrap', 'scale', 'fit'] = 'wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: ~typing.Literal['left', 'center', 'right'] = 'left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] = 'black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int] = (0, 0, 0, 0), padding: int = 0, emoji_source: type[~pilmoji.source.BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: ~fontstack._core.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[FontConfig] | None = None, *, size: int = 40, weight: int | str = 400, mode: Literal['wrap'] = 'wrap', max_width: int | None = None, align: Literal['left', 'center', 'right']='left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int]='black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int]=(0, 0, 0, 0), padding: int = 0, emoji_source: type[BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: FontManager) Image
- fontstack.draw_text(text: str, font_stack: list[FontConfig] | None = None, *, size: int = 40, weight: int | str = 400, mode: Literal['scale'] = 'wrap', max_width: int | None = None, min_size: int = 12, align: Literal['left', 'center', 'right']='left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int]='black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int]=(0, 0, 0, 0), padding: int = 0, emoji_source: type[BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: FontManager) Image
- fontstack.draw_text(text: str, font_stack: list[FontConfig] | None = None, *, size: int = 40, weight: int | str = 400, mode: Literal['fit'] = 'wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: Literal['left', 'center', 'right']='left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int]='black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int]=(0, 0, 0, 0), padding: int = 0, emoji_source: type[BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: FontManager) Image
- fontstack.draw_text(text: str, font_stack: list[FontConfig] | None = None, *, size: int = 40, weight: int | str = 400, mode: Literal['wrap', 'scale', 'fit']='wrap', max_width: int | None = None, max_height: int | None = None, min_size: int = 12, align: Literal['left', 'center', 'right']='left', line_spacing: float = 1.2, fill: str | int | tuple[int, int, int] | tuple[int, int, int, int]='black', background: str | int | tuple[int, int, int] | tuple[int, int, int, int]=(0, 0, 0, 0), padding: int = 0, emoji_source: type[BaseSource] = <class 'pilmoji.source.TwitterEmojiSource'>, manager: FontManager) Image
Render text and return a new PIL image sized to fit the result.
Creates a
FontManagerfrom font_stack (or reuses manager if provided), renders the text onto an oversized transparent canvas, crops the result to the exact pixel bounding box, adds padding on all sides, and composites onto background. When manager is passed, font_stack is ignored and the manager’s caches are reused across calls, which makes this efficient for batch rendering:mgr = FontManager(default_stack=[FontConfig(path="fonts/NotoSans.ttf")]) images = [draw_text(label, manager=mgr) for label in labels]
- Parameters:
text – The string to render. BiDi reordering and Arabic reshaping are applied automatically. May contain emoji.
font_stack – Ordered list of
FontConfigentries defining the fallback font stack. Ignored when manager is provided. May be omitted (None) when a manager is supplied directly.size – Initial font size in points. Defaults to
40.weight – Font weight - integer (e.g.
700) or named style string (e.g."Bold"). Defaults to400.mode –
"wrap"(word-wrap at max_width),"scale"(shrink to fit max_width), or"fit"(wrap then shrink to fit max_height). When max_width isNone, all modes render at full size with no constraint. Defaults to"wrap".max_width – Maximum pixel width for the text area.
Nonemeans no constraint. Defaults toNone.max_height – Maximum pixel height for the text block. Only used in
"fit"mode.Nonemeans no height constraint. Defaults toNone.min_size – Minimum font size floor for
"scale"and"fit"modes. Defaults to12.align – Horizontal alignment:
"left","center", or"right". Defaults to"left".line_spacing – Line height multiplier relative to size. Defaults to
1.2.fill – Text color. Accepts a Pillow color name, RGB tuple, or RGBA tuple (e.g.
(0, 0, 0, 128)for 50% opacity). Defaults to"black".background – Background color of the returned image. Defaults to fully transparent
(0, 0, 0, 0). Pass"white"or an RGB/RGBA tuple for an opaque background.padding – Uniform padding in pixels added to all four edges of the cropped result. Prevents glyphs from touching the image border. Defaults to
0.emoji_source – Pilmoji emoji source class. Defaults to
Twemoji.manager – An existing
FontManagerinstance to reuse. When provided, font_stack is ignored and no new manager is constructed. Useful for batch rendering where cmap caches should be shared across calls.
- Returns:
A new
"RGBA"image cropped tightly to the rendered text with padding added on all sides and background filled behind the text.- Return type:
Image.Image
- Raises:
ValueError – If neither font_stack nor manager is provided with usable font configuration.
Example
img = draw_text( "Hello مرحبا 世界 🌍", font_stack=[ FontConfig(path="fonts/NotoSans.ttf"), FontConfig(path="fonts/NotoSansCJK.ttc", ttc_index=0), FontConfig(path="fonts/NotoSansArabic.ttf"), ], size=48, weight=700, fill=(20, 20, 20), background="white", padding=16, ) img.save("hello.png")