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 weight argument passed to rendering methods always overrides the wght axis specifically. See VariationAxes for the full list of standard axes. Defaults to None.

  • ttc_index (int, optional) – Zero-based index of the desired font within a TrueType Collection (.ttc / .otc) file. Ignored for non-collection files. Defaults to 0.

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_index key in FontConfig. 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-reshaper to produce the correct contextual letter forms (initial, medial, final, isolated), then passed through python-bidi for 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 FontConfig entries. 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 to 30.

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 to 40.

  • weight – Font weight. Integer (e.g. 700) sets the wght variable axis; a string (e.g. "Bold") calls set_variation_by_name(). Static fonts ignore this silently. Defaults to 400.

  • mode

    Rendering mode. One of:

    "wrap"

    Word-wrap the text at max_width. Lines are spaced at size * line_spacing pixels. A single word wider than max_width will overflow without truncation. When max_width is None, 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 is None, this mode behaves identically to "wrap".

    Defaults to "wrap".

  • max_width – Maximum pixel width for the text area. None means no constraint. Defaults to None.

  • max_height – Maximum pixel height for the text block. Only used in "fit" mode. None means no height constraint. Defaults to None.

  • min_size – Minimum font size (points) used as the floor in "scale" and "fit" modes. Ignored in "wrap" mode. Defaults to 12.

  • 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.2 means each line’s top is size * 1.2 pixels below the previous line’s top. Increase for scripts with tall diacritics (e.g. Arabic tashkeel). Defaults to 1.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_stack for 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 FreeTypeFont objects 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 the wght variation axis, or a named style string (e.g. "Bold") passed to set_variation_by_name(). For static (non-variable) fonts the weight parameter is silently ignored.

  • custom_stack – Override the instance’s default_stack for 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. 400 is Regular, 700 is Bold.

Type:

float

wdth

Width axis. Typical range 50–200. 100 is normal width.

Type:

float

ital

Italic axis. 0 is upright, 1 is 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 FontManager from 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 FontConfig entries 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 to 400.

  • 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 is None, all modes render at full size with no constraint. Defaults to "wrap".

  • max_width – Maximum pixel width for the text area. None means no constraint. Defaults to None.

  • max_height – Maximum pixel height for the text block. Only used in "fit" mode. None means no height constraint. Defaults to None.

  • min_size – Minimum font size floor for "scale" and "fit" modes. Defaults to 12.

  • 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 FontManager instance 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")