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] | None = None, max_cache: int = 30, *, font_dir: str | Path | None = None)
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. Mutually exclusive with font_dir; at least one of the two must be provided.- param font_dir:
Path to a directory of font files (
.ttf,.otf,.ttc,.otc). When given, the directory is scanned viascan_font_dir()and the resultingFontConfiglist becomes thedefault_stack. Mutually exclusive with default_stack.- 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
# Explicit font stack manager = FontManager( default_stack=[ FontConfig(path="fonts/NotoSans.ttf"), FontConfig(path="fonts/NotoSansCJK.ttc", ttc_index=0), FontConfig(path="fonts/NotoSansArabic.ttf"), ] ) # Or scan from a directory manager = FontManager(font_dir="fonts/") 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, font_stack: list[~fontstack.types.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. A dash-separated string of colors ("red-blue","#FF0000-#00FF00-#0000FF") produces a linear gradient across the text (slightly diagonal so multi-line text gets natural color variation per line). The preset"rainbow"expands to"red-orange-yellow-green-blue-indigo-violet". Defaults to"black".stroke_width – Outline thickness in pixels drawn around each glyph.
0means no outline. Defaults to0.stroke_fill – Outline color. Accepts the same value types as fill, including gradient strings (e.g.
"rainbow","red-blue"). WhenNoneand stroke_width > 0, Pillow uses fill as the stroke color. Defaults toNone.shadow_color – Drop-shadow color. Accepts the same value types as fill, including gradient strings. The shadow shape includes the outline when stroke_width > 0.
Nonedisables the shadow. Defaults toNone.shadow_offset –
(x, y)pixel offset for the drop shadow relative to the main text position. Positive values shift right and down. Only used when shadow_color is notNone. Defaults to(2, 2).gradient_angle – Gradient direction in degrees, clockwise from horizontal.
0produces a pure left-to-right gradient; the default15.0adds a slight diagonal so multi-line text gets natural color variation per line. Only used when fill, stroke_fill, or shadow_color is a gradient string. Defaults to15.0.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".
- property font_dir: str | Path | None
The font directory passed at construction time, or
None.
- 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
OpenType variable-font axis overrides.
Each key is a registered axis tag and the value is the desired position on that axis. Only the axes listed here are set; all others keep their font-defined defaults.
Commonly used axes:
wghtWeight axis (100 = Thin … 400 = Regular … 700 = Bold … 900 = Black).
wdthWidth axis (percentage of normal width, e.g. 75 = Condensed, 100 = Normal).
italItalic axis (0 = Upright, 1 = Italic).
slntSlant axis (degrees of counter-clockwise slant, e.g. -12).
opszOptical-size axis (intended point size for optical compensation).
- ital: float
- opsz: float
- slnt: float
- wdth: float
- wght: float
- fontstack.draw_text(text: str, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | ~pathlib.Path | None = None, manager: ~fontstack.manager.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | ~pathlib.Path | None = None, manager: ~fontstack.manager.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | ~pathlib.Path | None = None, manager: ~fontstack.manager.FontManager | None = None) Image
- fontstack.draw_text(text: str, font_stack: list[~fontstack.types.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None = None, shadow_offset: tuple[int, int] = (2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | ~pathlib.Path | None = None, manager: ~fontstack.manager.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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path | None = None, 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path | None = None, 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path | None = None, 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path | None = None, manager: FontManager) 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path, manager: FontManager | None = None) 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path, manager: FontManager | None = None) 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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path, manager: 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', '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', stroke_width: int = 0, stroke_fill: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_color: str | int | tuple[int, int, int] | tuple[int, int, int, int] | None=None, shadow_offset: tuple[int, int]=(2, 2), gradient_angle: float = 15.0, 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'>, font_dir: str | Path, manager: FontManager | None = None) 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 font_dir or manager is supplied.font_dir – Path to a directory of font files. When given, a
FontManageris constructed from the scanned directory viascan_font_dir(). Ignored when manager is provided.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, RGBA tuple (e.g.
(0, 0, 0, 128)for 50% opacity), or a dash-separated gradient string (e.g."red-blue","#FF0000-#00FF00"). Gradients are slightly diagonal so multi-line text gets natural color variation. The preset"rainbow"expands to the seven spectral colors. Defaults to"black".stroke_width – Outline thickness in pixels around each glyph.
0disables the outline. Defaults to0.stroke_fill – Outline color. Accepts the same value types as fill, including gradient strings (e.g.
"rainbow").Noneuses fill as the stroke color when stroke_width > 0. Defaults toNone.shadow_color – Drop-shadow color. Accepts the same value types as fill, including gradient strings. The shadow shape includes the outline when stroke_width > 0.
Nonedisables the shadow. Defaults toNone.shadow_offset –
(x, y)pixel offset for the drop shadow. Defaults to(2, 2).gradient_angle – Gradient direction in degrees, clockwise from horizontal.
0produces a pure left-to-right gradient; the default15.0adds a slight diagonal so multi-line text gets natural color variation per line. Only used when fill, stroke_fill, or shadow_color is a gradient string. Defaults to15.0.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 none of font_stack, font_dir, or manager provides a 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")
- fontstack.scan_font_dir(font_dir: str | Path, *, recursive: bool = False) list[FontConfig]
Scan a directory for font files and return a list of
FontConfigentries suitable for use as adefault_stack.Recognised extensions:
.ttf,.otf,.ttc,.otc. TrueType/OpenType Collection files (.ttc,.otc) produce oneFontConfigper member font, each with the appropriatettc_index. Results are sorted by filename for deterministic fallback order.- Parameters:
font_dir – Path to the directory containing font files.
recursive – When
True, search subdirectories recursively. Defaults toFalse.
- Returns:
One or more
FontConfigentries found in font_dir.- Return type:
list[FontConfig]
- Raises:
FileNotFoundError – If font_dir does not exist or is not a directory.
ValueError – If no font files are found in font_dir.