2025, Dec 08 06:02

Как вращать текст в Pillow вокруг нижнего левого угла

Почему текст в Pillow смещается при повороте и как это исправить. Фиксируем якорь в нижнем левом углу, используем метрики шрифта и получаем позиционирование.

При повороте текста в Pillow картинка часто выглядит правильно, но текст оказывается не там, где нужно. Глифы действительно вращаются, однако их позиция плывет или уходит за границы целевого изображения, как только угол выходит за пределы первого квадранта. В этом руководстве объясняется, почему так происходит, и как «привязать» вращение к предсказуемой опоре, чтобы результат попадал ровно в нужную точку.

Минимальный пример, который демонстрирует сдвиг

Ниже фрагмент кода, который выводит текст на временное изображение, поворачивает его и вставляет на базовый холст. Для углов 0–90° всё выглядит корректно, но для остальных начинается дрейф из‑за расчета смещений.

from PIL import Image, ImageDraw, ImageFont

bg_img = Image.new("RGBA", (100, 100), (0, 255, 0))
label = 'Sample <del color=red>text<del>'
theta = 120
typeface = ImageFont.load_default(12)
SCALE = 4

scratch_draw = ImageDraw.Draw(
    Image.new("RGBA", (max(1, int(0 * SCALE)), max(1, int(0 * SCALE))), (0, 0, 0, 0))
)

bounds = scratch_draw.textbbox((0, 0), label, typeface)
label_w, label_h = bounds[2] - bounds[0], bounds[3] - bounds[1]

pad_img = Image.new(
    "RGBA", (max(1, int(label_w * SCALE)), max(1, int(label_h * SCALE))), (0, 0, 0, 0)
)
ink = ImageDraw.Draw(pad_img)
ink.text((0, 0), label, (0, 0, 0), typeface)

if 0 <= theta <= 360:
    pad_img = pad_img.rotate(-theta, expand=True, resample=Image.BICUBIC)
    box = pad_img.getbbox()
    w, h = pad_img.size

    if 0 <= theta <= 90:
        x_off = -box[0]
        y_off = -box[1]
    elif 90 < theta <= 180:
        x_off = -box[0]
        y_off = -box[1]
    elif 180 <= theta <= 270:
        x_off = -(w - box[2])
        y_off = -(h - box[3])
    elif 270 <= theta < 360:
        x_off = -box[0]
        y_off = -(h - box[3])

bg_img.paste(pad_img, (x_off, y_off), pad_img)
bg_img.show()

Почему текст смещается

Поворот применяется к растровому изображению с текстом, а не к абстрактной векторной базовой линии. У этого изображения есть отступы, зависящие от конкретных глифов. Буквы с нижними выносными, такие как y, j или g, меняют эффективную рамку и, как следствие, габариты после вращения. Ручная логика смещений, привязанная к квадрантам, не может стабильно учитывать эти различия; поэтому она работает лишь в узком диапазоне вроде 0–90° и начинает давать сбои на других углах.

Надежный подход — выбрать явную точку опоры для вращения. Если нужно вращать вокруг нижнего левого угла текста, компоновка должна гарантировать, что именно этот угол станет геометрическим центром вращения. Тогда одна и та же привязка будет попадать в одно и то же место при любом угле, без квадрантных «костылей».

Исправление через геометрию: вращение вокруг нижнего левого угла

Идея проста. Сначала отрисуйте текст с постоянной высотой по метрикам шрифта, используя real_height = ascender + descender, чтобы избежать «скачков» базовой линии из‑за нижних выносных. Затем поместите текст на больший холст так, чтобы его нижний левый угол точно совпадал с центром холста. После этого поверните весь холст вокруг центра. Наконец, вставьте повернутый результат в целевое изображение с фиксированным смещением, равным половине размеров повернутого холста, — так выбранная опора останется зафиксированной.

import sys
from PIL import Image, ImageFont, ImageDraw

DEMO = True

if DEMO:
    BG = "gray"  # видимый фон для тестов
else:
    BG = (0, 0, 0, 0)

# угол из аргументов или значение по умолчанию для примера
if len(sys.argv) == 1:
    deg = 45 + 90
else:
    deg = int(sys.argv[1])

# используем метрики шрифта, чтобы стабилизировать вертикальное положение независимо от нижних выносных
# [Якоря текста — документация Pillow (PIL Fork) 11.3.0]
# https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors

face = ImageFont.truetype("Arial.ttf", 40)
asc, desc = face.getmetrics()
full_h = asc + desc
print(f"full_h = {full_h}")

text = "Stackoverflow"

x0, y0, x1, y1 = face.getbbox(text)
box_w = x1 - x0
box_h = y1 - y0
print("box xy:", x0, y0, x1, y1)
print("box w, h:", box_w, box_h)

# 1) рендерим текст с постоянной высотой
stamp = Image.new("RGBA", (box_w, full_h), BG)
pen = ImageDraw.Draw(stamp)
pen.text((-x0, 0), text, font=face)

stamp.save("output-1-text.png")

# 2) центрируем нижний левый угол на большем холсте
stage = Image.new("RGBA", (box_w * 2, full_h * 2), BG)
stage.paste(stamp, (box_w, 0))

# вспомогательная разметка, чтобы визуализировать центр вращения
if DEMO:
    g = ImageDraw.Draw(stage)
    g.circle((box_w, full_h), 2, fill="red")
    g.circle((box_w, full_h), 5, outline="red")
    g.line((box_w - 10, full_h, box_w + 10, full_h), fill="red", width=1)
    g.line((box_w, full_h - 10, box_w, full_h + 10), fill="red", width=1)

stage.save("output-2-for-rotation.png")

# 3) поворачиваем вокруг центра
turned = stage.rotate(-deg, expand=True)
rot_w, rot_h = turned.size
print("rotated:", rot_w, rot_h)

turned.save(f"output-3-rotated-{deg}.png")

# 4) вставляем так, чтобы якорь попал в нужную точку
shift_x = -rot_w // 2
shift_y = -rot_h // 2

final = Image.new("RGBA", (box_w, box_w), "green")
final.alpha_composite(turned, dest=(shift_x, shift_y))
final.save(f"output-4-final-{deg}.png")

Что это меняет

Точка опоры становится явной и стабильной, потому что нижний левый угол текста превращается в центр холста до вращения. Вращение вокруг этого центра удерживает якорь на месте при любом угле. Высота растрового штампа, равная ascender + descender, устраняет зависящие от строки вертикальные поля, поэтому последовательности вроде y j g не сдвигают базовую линию и не ломают выравнивание. Этап вставки сводится к постоянному смещению, вычисляемому только из размеров повернутого холста, а не из ветвления по квадрантам.

Почему это важно

Расположение текста, зависящее от особенностей ограничивающих рамок, приводит к ошибкам на отдельных углах и к неприятным сюрпризам на разных строках. Подход «сначала геометрия» обеспечивает предсказуемое позиционирование по всему диапазону 0–360° и для разных надписей. Он также устраняет необходимость в условиях по квадрантам и разовых поправках, которые сложно поддерживать.

Выводы

Осознанно выбирайте якорь вращения и стройте композицию вокруг него. Для привязки к нижнему левому углу центрируйте этот угол на большом холсте, вращайте вокруг центра и вставляйте с фиксированным смещением, равным половине размеров. Пользуйтесь метриками шрифта, чтобы сохранить постоянную вертикальную «высоту» независимо от выносных элементов. Так вы избавитесь от хрупкой логики смещений и получите корректное размещение на любом угле.