2025, Nov 25 21:00
Rotate text in Pillow without drift: bottom-left anchoring and font-metrics-based placement
Learn why Pillow text rotation drifts and fix it by anchoring bottom-left using font metrics. Stable 0-360 placement across angles no quadrant-specific offsets.
Rotating text with Pillow often looks right, yet ends up in the wrong place. The glyphs rotate, but their position drifts or slips outside the target image when the angle moves beyond the first quadrant. This guide shows why that happens and how to lock the rotation to a predictable anchor so the result lands exactly where you expect.
Minimal example that exposes the shift
The following snippet draws text to a temporary image, rotates it, and pastes onto a base canvas. It behaves correctly for 0–90°, but starts drifting for other angles because of the offset math.
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()
Why the text shifts
The rotation is applied to a raster image that contains the text, not to an abstract vector baseline. That image has margins that depend on the glyphs present. Letters with descenders, such as y, j, or g, change the effective box and therefore the post-rotation bounding box. The manual offset logic tied to quadrants can’t account for these differences consistently, which is why it only holds in a narrow range like 0–90° and starts to slip for other angles.
The robust approach is to select an explicit pivot for rotation. If the goal is to rotate around the bottom-left corner of the text, then the composition should guarantee that this corner becomes the geometric center of rotation. With that, the same anchor lands in the same place for all angles without quadrant-specific tweaks.
Geometry-first fix: rotate around the bottom-left corner
The idea is simple. First, render the text with a consistent height based on font metrics, using real_height = ascender + descender to avoid baseline jumps caused by descenders. Next, place the text onto a larger canvas so that its bottom-left corner is exactly at the center of this canvas. Then rotate the entire canvas around its center. Finally, paste the rotated result onto the destination using a fixed offset equal to half of the rotated size, so the chosen anchor stays locked.
import sys
from PIL import Image, ImageFont, ImageDraw
DEMO = True
if DEMO:
BG = "gray" # visible background for tests
else:
BG = (0, 0, 0, 0)
# angle from args or a default sample
if len(sys.argv) == 1:
deg = 45 + 90
else:
deg = int(sys.argv[1])
# use font metrics to stabilize vertical placement regardless of descenders
# [Text anchors - Pillow (PIL Fork) 11.3.0 documentation]
# 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) render text with consistent height
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) center bottom-left corner on a larger canvas
stage = Image.new("RGBA", (box_w * 2, full_h * 2), BG)
stage.paste(stamp, (box_w, 0))
# helpers to visualize the rotation center
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) rotate around center
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) paste so the anchor lands in the expected position
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")
What this changes
The pivot is explicit and stable because the bottom-left corner of the text becomes the center of the pre-rotation canvas. Rotating around that center keeps the anchor fixed for every angle. Using ascender + descender for the raster height neutralizes text-dependent vertical margins so strings like y j g don’t shift the baseline and break alignment. The paste step becomes a constant offset derived purely from the rotated canvas size, not from angle-specific branching.
Why this matters
Text placement that depends on bounding-box quirks leads to angle-specific bugs and content-specific surprises. A geometry-first method produces predictable positioning across the full 0–360° range and across different strings. It also removes the need for per-quadrant conditions and ad hoc corrections that are hard to maintain.
Takeaways
Choose the rotation anchor deliberately and design the composition around it. For bottom-left anchoring, center that corner on a larger canvas, rotate around the center, and paste with a fixed half-size offset. Use font metrics to keep the vertical footprint consistent regardless of descenders. This removes fragile offset logic and yields correct placement at any angle.