2025, Nov 06 17:00

Make IME composition text match Tkinter widget fonts on Windows using ImmSetCompositionFontW

Learn why IME composition ignores Tkinter fonts on Windows and how to fix it with ImmSetCompositionFontW and LOGFONTW, matching Entry/Text preview with your UI.

When you tune fonts for Tkinter widgets, you expect every piece of text in Entry and Text to follow suit. The surprise comes with IME composition: the preview text that appears while you’re composing input through an IME ignores the widget’s font and falls back to a default. The result is mismatched size and family, and even a slight visual offset compared to submitted text.

Reproducing the issue

The example below sets explicit fonts for labels and inputs. Regular keystrokes render correctly, but IME composition text still uses a different, default look.

#!/usr/bin/env python3
from contextlib import suppress
import tkinter as tk
import tkinter.font as tkfont
import platform
import ctypes
view_size = (800, 400)
def set_dpi_awareness() -> bool:
    with suppress(AttributeError):
        return ctypes.windll.shcore.SetProcessDpiAwareness(2) == 0
    with suppress(AttributeError):
        return ctypes.windll.user32.SetProcessDPIAware() != 0
    return True
def run_app() -> None:
    root = tk.Tk()
    root.title("Text Edit")
    root.geometry("%dx%d" % view_size)
    root.minsize(*view_size)
    lbl_face = tkfont.Font(name="lbl_face", family="微軟正黑體", size=12, weight=tkfont.NORMAL)
    io_face = tkfont.Font(name="io_face", family="微軟正黑體", size=16, weight=tkfont.NORMAL)
    header = tk.Frame(root, pady=10)
    tk.Label(header, text="Title:", font=lbl_face).pack(side=tk.LEFT)
    title_input = tk.Entry(header, font=io_face)
    title_input.pack(side=tk.LEFT)
    header.grid(row=0, column=0, columnspan=2)
    editor = tk.Text(root, wrap=tk.WORD, font=io_face)
    vbar = tk.Scrollbar(root, command=editor.yview)
    editor.configure(yscrollcommand=vbar.set)
    editor.grid(row=1, column=0, sticky=tk.NSEW)
    vbar.grid(row=1, column=1, sticky=tk.NS)
    root.rowconfigure(1, weight=1)
    root.columnconfigure(0, weight=1)
    root.mainloop()
if __name__ == "__main__":
    if platform.system() == "Windows":
        set_dpi_awareness()
    run_app()

Type some ASCII text, then switch to an IME and start composing. The preview string will be rendered with a different font family and size, independently of the widget’s font.

Why this happens

IME composition is handled by the OS. While Tkinter widgets expose a font option and Tk has named fonts and ttk styles, those settings don’t affect the IME composition window. Adjusting all built-in Tk font names, styling the root, or configuring the Tk root with a font has no effect on the IME preview text. Documentation mentions IME support at the toolkit level, but in practice the composition font here follows a separate path on Windows.

A practical fix on Windows

On Windows, you can explicitly set the composition font with the ImmSetCompositionFontW API. Detect when IME composition starts, convert the widget’s current Tk font to a LOGFONTW structure, and pass it to the input context. This aligns the preview with the widget font.

import ctypes
import platform
import tkinter as tk
from contextlib import suppress
from ctypes import c_byte, c_int, c_long, c_wchar
from tkinter import font as tkfont
from typing import Any
canvas_size = (800, 400)
IME_VKEY = 229
DPIY_INDEX = c_int(90)
CHARSET_DEFAULT = c_byte(1)
LF_FACESIZE = 32
POINTS_PER_INCH = 72
WEIGHT_MAP = {
    tkfont.NORMAL: c_long(400),
    tkfont.BOLD: c_long(700),
}
class LOGFONTW(ctypes.Structure):
    _fields_ = [
        ("lfHeight", c_long),
        ("lfWidth", c_long),
        ("lfEscapement", c_long),
        ("lfOrientation", c_long),
        ("lfWeight", c_long),
        ("lfItalic", c_byte),
        ("lfUnderline", c_byte),
        ("lfStrikeOut", c_byte),
        ("lfCharSet", c_byte),
        ("lfOutPrecision", c_byte),
        ("lfClipPrecision", c_byte),
        ("lfQuality", c_byte),
        ("lfPitchAndFamily", c_byte),
        ("lfFaceName", c_wchar * LF_FACESIZE),
    ]
def tune_dpi() -> bool:
    with suppress(AttributeError):
        return ctypes.windll.shcore.SetProcessDpiAwareness(2) == 0
    with suppress(AttributeError):
        return ctypes.windll.user32.SetProcessDPIAware() != 0
    return True
class ImeAwareApp(tk.Tk):
    def __init__(self, *args: Any, **kwargs: Any):
        tk.Tk.__init__(self, *args, **kwargs)
        self.title("Text Edit")
        self.geometry("%dx%d" % canvas_size)
        self.minsize(*canvas_size)
        self.lbl_face = tkfont.Font(name="lbl_face", family="微軟正黑體", size=12, weight=tkfont.NORMAL)
        self.io_face = tkfont.Font(name="io_face", family="微軟正黑體", size=16, weight=tkfont.NORMAL)
        strip = tk.Frame(self, pady=10)
        tk.Label(strip, text="Title:", font=self.lbl_face).pack(side=tk.LEFT)
        entry_box = tk.Entry(strip, font=self.io_face)
        entry_box.pack(side=tk.LEFT)
        strip.grid(row=0, column=0, columnspan=2)
        editor = tk.Text(self, wrap=tk.WORD, font=self.io_face)
        scroll = tk.Scrollbar(self, command=editor.yview)
        editor.configure(yscrollcommand=scroll.set)
        editor.grid(row=1, column=0, sticky=tk.NSEW)
        scroll.grid(row=1, column=1, sticky=tk.NS)
        self.rowconfigure(1, weight=1)
        self.columnconfigure(0, weight=1)
        self.bind_all("<Key>", self.apply_ime_face)
        self._ime_live = False
    def apply_ime_face(self, ev: "tk.Event[tk.Misc]"):
        if ev.keycode == IME_VKEY and not self._ime_live:
            self._ime_live = True
            user32 = ctypes.WinDLL(name="user32")
            imm32 = ctypes.WinDLL(name="imm32")
            gdi32 = ctypes.WinDLL(name="gdi32")
            hwnd = user32.GetForegroundWindow()
            hdc = user32.GetDC(hwnd)
            imc = imm32.ImmGetContext(hwnd)
            tkf = tkfont.nametofont(ev.widget.cget("font"))
            lf = LOGFONTW()
            sz = tkf.cget("size")
            lf.lfHeight = c_long(
                -round(sz * gdi32.GetDeviceCaps(hdc, DPIY_INDEX) / POINTS_PER_INCH) if sz > 0 else sz
            )
            lf.lfWidth = c_long(0)
            lf.lfEscapement = c_long(0)
            lf.lfOrientation = c_long(0)
            lf.lfWeight = WEIGHT_MAP[tkf.cget("weight")]
            lf.lfItalic = c_byte(int(tkf.cget("slant") == "italic"))
            lf.lfUnderline = c_byte(tkf.cget("underline"))
            lf.lfStrikeOut = c_byte(tkf.cget("overstrike"))
            lf.lfCharSet = CHARSET_DEFAULT
            lf.lfOutPrecision = c_byte(0)
            lf.lfClipPrecision = c_byte(0)
            lf.lfQuality = c_byte(0)
            lf.lfPitchAndFamily = c_byte(0)
            lf.lfFaceName = tkf.cget("family")
            imm32.ImmSetCompositionFontW(imc, lf)
        if ev.char and self._ime_live:
            self._ime_live = False
if __name__ == "__main__":
    if platform.system() == "Windows":
        tune_dpi()
    app = ImeAwareApp()
    app.mainloop()

This approach hinges on two observations from testing. First, while composing, a Key event with keycode 229 is delivered. That indicates IME composition has begun and an IME context is active, so setting the composition font succeeds at that point. Second, once a character is delivered, composition has finished and you can reset state. Setting the composition font only when composition starts avoids trying to apply it when no IME is active.

The LOGFONTW struct faithfully mirrors the Tkinter font. Height uses points-to-pixels conversion via GetDeviceCaps and a 72 points-per-inch base; positive Tk sizes are treated as points and converted, while negative sizes are already pixel units and are passed through. The face name must be defined as a c_wchar array of length 32, and mapping the weight preserves NORMAL and BOLD.

Why this matters

Users notice when preview glyphs don’t match the rest of the UI. Keeping IME composition text identical to the surrounding content eliminates a distracting mismatch and the slight placement drift that comes with default fallback fonts. It’s especially visible in mixed Latin–CJK text and when you’ve standardized typography across Entry and Text.

Takeaways

Widget font options, Tk named fonts, and ttk styles don’t influence IME preview text on Windows. If you want the IME composition to follow your widget font, use ImmSetCompositionFontW and apply it when composition starts. There isn’t a built-in Tkinter hook that does this for you, and the Windows-specific API won’t apply on Linux.

If you rely on consistent text rendering in inputs that accept IME-based input, wire this up early in your application and convert your Tkinter font to LOGFONTW carefully. That gives you the same family and size for both submitted text and the IME preview, aligning the visual language across the whole editor.

The article is based on a question from StackOverflow by I Like Python and an answer by Henry.