2025, Nov 09 00:02

Шрифт IME в Tkinter на Windows: решение через ImmSetCompositionFontW

При вводе через IME в Tkinter на Windows предпросмотр рисуется другим шрифтом. Почему так происходит и как задать единый шрифт через ImmSetCompositionFontW.

Когда вы настраиваете шрифты для виджетов Tkinter, логично ожидать, что любой текст в Entry и Text будет им соответствовать. Но при составлении (composition) в IME возникает сюрприз: предварительный текст, который появляется во время набора через IME, игнорирует шрифт виджета и возвращается к значению по умолчанию. В результате меняется гарнитура и размер, а иногда появляется небольшое визуальное смещение относительно окончательно вставленного текста.

Как воспроизвести проблему

В примере ниже для меток и полей ввода заданы явные шрифты. Обычные нажатия клавиш отображаются корректно, но текст составления IME по‑прежнему рисуется другим, стандартным шрифтом.

#!/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()

Наберите немного ASCII‑текста, затем переключитесь на IME и начните составление. Строка предварительного просмотра будет отрисована с другой гарнитурой и размером, независимо от шрифта виджета.

Почему так происходит

Составление IME обрабатывается операционной системой. Хотя виджеты Tkinter предоставляют параметр шрифта, а в Tk есть именованные шрифты и стили ttk, эти настройки не влияют на окно композиции IME. Изменение всех встроенных имен шрифтов Tk, стилизация корневого окна или назначение шрифта для корня Tk не меняют вид предварительного текста. В документации упоминается поддержка IME на уровне тулкита, но на практике на Windows шрифт для композиции проходит по отдельному пути.

Практическое решение в Windows

В Windows можно явно задать шрифт композиции с помощью API ImmSetCompositionFontW. Определите момент начала составления IME, преобразуйте текущий шрифт Tk виджета в структуру LOGFONTW и передайте её в контекст ввода. Так предпросмотр будет соответствовать шрифту виджета.

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()

Такой подход опирается на два наблюдения из практики. Во‑первых, в процессе составления приходит событие Key с кодом клавиши 229. Это сигнализирует о начале композиции IME и наличии активного контекста IME, поэтому установка шрифта композиции в этот момент проходит успешно. Во‑вторых, как только доставляется символ, составление завершается, и можно сбросить состояние. Применяя шрифт только при старте композиции, вы избегаете попыток настроить его, когда IME ещё не активен.

Структура LOGFONTW точно повторяет параметры шрифта Tkinter. Высота рассчитывается через перевод пунктов в пиксели с помощью GetDeviceCaps и базовой величины 72 точки на дюйм: положительные размеры Tk трактуются как пункты и конвертируются, а отрицательные уже выражены в пикселях и передаются как есть. Имя гарнитуры задаётся массивом c_wchar длиной 32, а сопоставление толщины сохраняет значения NORMAL и BOLD.

Зачем это важно

Пользователи замечают, когда символы предварительного просмотра выбиваются из общего стиля интерфейса. Если сделать текст композиции IME идентичным окружению, исчезает отвлекающее несоответствие и лёгкий «дрейф» позиционирования, возникающий из‑за шрифтов по умолчанию. Это особенно заметно при смешанном латинице и CJK и когда вы унифицировали типографику в Entry и Text.

Итоги

Параметры шрифтов виджетов, именованные шрифты Tk и стили ttk не влияют на текст предварительного просмотра IME в Windows. Если хотите, чтобы композиция IME следовала шрифту вашего виджета, используйте ImmSetCompositionFontW и применяйте его в момент начала композиции. Встроенного механизма Tkinter для этого нет, а специфичный для Windows API на Linux не работает.

Если для вас важна единообразная отрисовка текста в полях ввода, где используется IME, подключите это решение на раннем этапе работы приложения и аккуратно преобразуйте шрифт Tkinter в LOGFONTW. Тогда и введённый текст, и предпросмотр IME будут иметь одну и ту же гарнитуру и кегль, поддерживая единую визуальную систему всего редактора.

Статья основана на вопросе с StackOverflow от I Like Python и ответе от Henry.