Resize and Position Images in Excel with openpyxl
A logo in the wrong place makes a generated report look generated. openpyxl inserts images in two lines, and then the questions start: how big, measured in what, anchored to what, and why did it jump three columns when the widths changed? The answers come down to pixels versus EMU and to which anchor type you choose. This guide covers sizing without distortion, precise placement, and the round-trip behaviour that quietly drops images from a template. It extends Inserting Images and Logos into Excel.
Prerequisites
pip install openpyxl pillow
Pillow is not optional here — openpyxl uses it to read the image's dimensions, and without it Image raises on anything but the simplest case.
from openpyxl import Workbook
from openpyxl.drawing.image import Image
wb = Workbook()
ws = wb.active
ws["A1"] = "Regional Revenue Report"
img = Image("logo.png")
ws.add_image(img, "E1")
wb.save("report.xlsx")
That inserts the image at its natural size, top-left corner in cell E1. Everything else is refinement.
Step 1 — Size without distortion
width and height are in pixels at 96 dpi. Setting both independently stretches the image, so compute one from the other:
from openpyxl.drawing.image import Image
from PIL import Image as PILImage
def sized_image(path, width=None, height=None):
"""Load an image scaled to a target width or height, preserving proportions."""
if width is None and height is None:
return Image(path)
with PILImage.open(path) as source:
native_w, native_h = source.size
ratio = native_h / native_w
if width is not None:
target_w, target_h = width, round(width * ratio)
else:
target_h, target_w = height, round(height / ratio)
img = Image(path)
img.width, img.height = target_w, target_h
return img
logo = sized_image("logo.png", width=180)
ws.add_image(logo, "E1")
Passing one dimension and deriving the other is the whole discipline. A logo squashed by ten per cent is the kind of thing nobody can name but everybody notices.
Make the row tall enough, or the image overlaps the data below it. Row height is in points, not pixels, and the conversion is 0.75 points per pixel:
PX_TO_POINTS = 0.75
def fit_row_to_image(ws, row, image, padding_px=8):
"""Set a row's height so an image sits inside it."""
ws.row_dimensions[row].height = (image.height + padding_px) * PX_TO_POINTS
Three different units in play — pixels for images, points for row heights, characters for column widths — is genuinely confusing, and it is why placement often needs one round of trial and error.
Step 2 — Anchor precisely
A plain cell reference pins the top-left corner to that cell's top-left corner. For finer control, build the anchor yourself. Offsets are in EMU, where 9,525 EMU is one pixel:
from openpyxl.drawing.spreadsheet_drawing import (
AnchorMarker, OneCellAnchor,
)
from openpyxl.drawing.xdr import XDRPositiveSize2D
from openpyxl.utils.units import pixels_to_EMU
EMU_PER_PIXEL = 9525
def anchored_image(path, col, row, width=None, height=None,
col_offset_px=0, row_offset_px=0):
"""An image at an exact offset inside a cell, with a fixed size."""
img = sized_image(path, width=width, height=height)
marker = AnchorMarker(
col=col, row=row, # zero-based
colOff=pixels_to_EMU(col_offset_px),
rowOff=pixels_to_EMU(row_offset_px),
)
img.anchor = OneCellAnchor(
_from=marker,
ext=XDRPositiveSize2D(
pixels_to_EMU(img.width), pixels_to_EMU(img.height)
),
)
return img
# 12 pixels in and 6 down from the top-left of E2 (col 4, row 1 zero-based).
ws.add_image(anchored_image("logo.png", col=4, row=1, width=180,
col_offset_px=12, row_offset_px=6))
Note the zero-based col and row in AnchorMarker, against openpyxl's one-based cell addressing everywhere else. Column E is col=4, row 2 is row=1. Getting this wrong shifts the image one cell up and left, which looks like an off-by-one because it is one.
OneCellAnchor with an explicit ext is what keeps the size fixed. The image still moves if the anchoring cell moves, but it never stretches — which is the behaviour a logo wants.
For an image that should fill a region and resize with it, use TwoCellAnchor instead:
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor
def stretched_image(path, from_col, from_row, to_col, to_row):
"""An image stretched between two cells — resizes with the layout."""
img = Image(path)
img.anchor = TwoCellAnchor(
editAs="twoCell",
_from=AnchorMarker(col=from_col, row=from_row),
to=AnchorMarker(col=to_col, row=to_row),
)
return img
# Fill the block from E10 to K24.
ws.add_image(stretched_image("chart.png", 4, 9, 10, 23))
That is right for a rendered chart image meant to occupy a panel, and wrong for a logo, because the aspect ratio follows the cells rather than the image.
Step 3 — Lay out several images
A report footer with several logos, or a gallery of rendered charts, needs positions computed rather than typed.
from openpyxl import Workbook
def place_row(ws, paths, start_col=1, start_row=1, stride=4, width=160,
gap_px=8):
"""Place images left to right, evenly spaced."""
placed = []
for index, path in enumerate(paths):
img = anchored_image(
path,
col=start_col + index * stride,
row=start_row,
width=width,
col_offset_px=gap_px,
row_offset_px=gap_px,
)
ws.add_image(img)
placed.append(img)
if placed:
tallest = max(i.height for i in placed)
ws.row_dimensions[start_row + 1].height = (tallest + 2 * gap_px) * 0.75
return placed
def place_grid(ws, paths, columns=2, start_col=1, start_row=1,
col_stride=6, row_stride=16, width=280):
"""Place images in a grid — useful for a page of rendered charts."""
for index, path in enumerate(paths):
down, across = divmod(index, columns)
ws.add_image(anchored_image(
path,
col=start_col + across * col_stride,
row=start_row + down * row_stride,
width=width,
))
Computing the positions means adding a fifth chart is a list entry rather than four new cell references, and changing the spacing is one number. That composes well with the matplotlib workflow in embedding a matplotlib chart in an Excel report.
Step 4 — Remember that images do not survive a round trip
This is the behaviour that costs the most time. openpyxl drops images that were already in a workbook when it loads it — they are not parsed into the object model, so they are not written back:
from openpyxl import load_workbook
wb = load_workbook("template_with_logo.xlsx")
print(len(wb.active._images)) # 0 — the logo is already gone
wb.save("output.xlsx") # output has no logo
There is no flag to change it. The practical consequences:
- Do not rely on a template's logo. Re-add every image your output needs, from files your script controls.
- Keep the image assets with the code, so a report can always be rebuilt identically.
- Use LibreOffice for a true copy when a template's images genuinely must survive untouched —
soffice --headless --convert-to xlsxpreserves them, as in converting .xls to .xlsx.
A helper that always re-applies the branding makes this a non-issue:
from pathlib import Path
BRANDING = Path("assets")
def apply_branding(ws, logo="logo.png", col=0, row=0, width=180):
"""Re-add the logo. Call on every generated workbook, always."""
path = BRANDING / logo
if not path.exists():
raise FileNotFoundError(f"branding asset missing: {path}")
ws.add_image(anchored_image(str(path), col=col, row=row, width=width,
col_offset_px=8, row_offset_px=6))
ws.row_dimensions[row + 1].height = (width * 0.35 + 12) * 0.75
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Logo looks stretched | width and height set independently | Derive one from the source aspect ratio. |
| Image overlaps the data below | Row too short | Set the row height, remembering points ≠ pixels. |
| Image one cell up and left | AnchorMarker is zero-based | Column E is col=4, row 2 is row=1. |
| Image resizes when a column widens | TwoCellAnchor used | Use OneCellAnchor with an explicit ext. |
| Template logo missing from the output | openpyxl drops loaded images | Re-add images in the script. |
ImportError on Image | Pillow not installed | pip install pillow. |
| Offsets have no effect | Offsets given in pixels | Convert with pixels_to_EMU. |
| Image not in the printed output | Falls outside the print area | Set ws.print_area to include it. |
Performance and scale notes
Images are embedded, not referenced, so each one adds its full byte size to the workbook. A 2 MB PNG logo on twelve sheets is 24 MB of report — and the same logo at 40 KB is 480 KB, which nobody notices.
Three habits keep report files small. Resize the source, not just the display. Setting img.width scales the rendering while embedding the original bytes, so a 3,000-pixel-wide logo displayed at 180 pixels still costs its full size. Downscale the file once:
from PIL import Image as PILImage
def prepare_asset(src, dest, max_width=400):
"""Downscale a source image once so every report embeds a small file."""
with PILImage.open(src) as image:
if image.width > max_width:
ratio = max_width / image.width
image = image.resize((max_width, round(image.height * ratio)),
PILImage.LANCZOS)
image.save(dest, optimize=True)
return dest
Choose the format deliberately. PNG for logos and anything with flat colour or transparency; JPEG for photographs, where it is several times smaller. A screenshot saved as JPEG looks blurry around text, and a photograph saved as PNG is needlessly large.
Insert each distinct image once per workbook where you can. openpyxl embeds a separate copy per add_image call, so a logo repeated on twelve sheets is stored twelve times. Where the file size matters more than the repetition, put the logo on the first sheet only and rely on a header row elsewhere.
For very large reports, note that images cannot be added in write_only mode — that mode streams rows and never holds the drawing collection. Stream the data first, then re-open the workbook normally to add images, which is cheap because the second pass touches only the drawing parts. The streaming approach itself is covered in writing large DataFrames with write-only mode.
Conclusion
Placing an image well comes down to three things. Size it from one dimension and derive the other from the source aspect ratio, so it is never stretched. Anchor it with a OneCellAnchor and an explicit extent when it is a logo that must keep its proportions, or a TwoCellAnchor when it is a chart image meant to fill a region. And remember the units differ at every level — pixels for images, points for row heights, EMU for offsets at 9,525 to the pixel, and zero-based indexes in AnchorMarker where the rest of openpyxl is one-based. Then re-add every image on every run, because openpyxl will not carry a template's images through.
Frequently asked questions
What units are the width and height in? Pixels, at 96 dots per inch. openpyxl converts them to the EMU units the file format uses, so you set a sensible pixel size and it handles the rest.
Why is my logo stretched?
You set width and height independently. Compute one from the other using the source image's aspect ratio, which Pillow reports, so the proportions are preserved.
Why did the image move when I widened a column?
The default anchor ties the image to a cell, so it follows that cell as the layout changes. Use a OneCellAnchor with an explicit extent to keep the size fixed, or accept the movement as intended behaviour.
How do I place an image at an exact offset within a cell?
Build an AnchorMarker with col, row, colOff and rowOff values. The offsets are in EMU, where 9,525 EMU is one pixel, so convert your pixel offset with pixels_to_EMU.
Do images survive an openpyxl round trip? No. openpyxl drops images that were already in a workbook when it loads it, so re-add every image your output needs rather than assuming the ones in a template will persist.
Related
- Up to the parent: Inserting Images and Logos into Excel — the insertion basics.
- Add a Logo Image to an Excel Report with openpyxl — the branding pass this refines.
- Embed a matplotlib Chart in an Excel Report — generating the images this places.
- Set Column Width and Row Height in openpyxl — the dimensions images sit inside.
- Populate an Excel Template Without Losing Formatting — why a template's images need re-adding.