Extract Images from an Excel File with Python
Workbooks collect pictures: product photos pasted into a catalogue, scanned receipts attached to an expenses sheet, signatures on an approval form. Sooner or later someone needs them out — to load into a product database, to archive, or to check that every row has one. Excel's own answer is to save the workbook as a web page and dig through a folder. Python can do better, and can keep the link between each picture and the row it belongs to. This guide, part of Inserting Images and Logos into Excel, covers the three levels of detail you might need.
Prerequisites
pip install openpyxl pillow xlsxwriter
This builds a workbook containing one floating logo and two pictures placed inside cells, so every technique on the page has something to find:
import xlsxwriter
from PIL import Image, ImageDraw
for name, colour in [("logo", "#4338ca"), ("widget", "#0f9488"), ("gadget", "#b4740a")]:
picture = Image.new("RGB", (200, 200), "white")
ImageDraw.Draw(picture).ellipse((20, 20, 180, 180), fill=colour)
picture.save(f"{name}.png")
book = xlsxwriter.Workbook("catalog.xlsx")
sheet = book.add_worksheet("Products")
sheet.insert_image("F1", "logo.png", {"x_scale": 0.3, "y_scale": 0.3})
sheet.write_row(0, 0, ["SKU", "Name", "Photo"])
for row, (sku, name) in enumerate([("W-100", "widget"), ("G-200", "gadget")], start=1):
sheet.set_row(row, 60)
sheet.write_row(row, 0, [sku, name])
sheet.embed_image(row, 2, f"{name}.png")
book.close()
Step 1 — Extract every image from the archive
An .xlsx file is a zip archive, and every picture in it — floating or in a cell, on any sheet — is stored as an ordinary image file under
xl/media/. When you only need the files, the standard library is enough.
import zipfile
from pathlib import Path
def extract_all_media(xlsx: str, out_dir: str) -> list[Path]:
target = Path(out_dir)
target.mkdir(parents=True, exist_ok=True)
saved = []
with zipfile.ZipFile(xlsx) as archive:
for info in archive.infolist():
if info.filename.startswith("xl/media/"):
path = target / Path(info.filename).name
path.write_bytes(archive.read(info))
saved.append(path)
return saved
print(extract_all_media("catalog.xlsx", "media_out"))
This is fast and reliable, but it loses context: files are named image1.png, image2.png in the order the writing application chose, and one
picture used in many cells appears only once. For an archive or a quick audit that is fine; for loading photos against products it is not.
Step 2 — Extract floating images with their cell anchors
Floating pictures are tied to a cell through an anchor in the sheet's drawing part. openpyxl loads them onto each worksheet, where the (private but
stable) _images list gives access to both the bytes and the anchor. The anchor's _from marker holds zero-based row and column numbers.
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
def extract_floating(xlsx: str, out_dir: str) -> list[dict]:
target = Path(out_dir)
target.mkdir(parents=True, exist_ok=True)
found = []
for ws in load_workbook(xlsx).worksheets:
for number, image in enumerate(ws._images, start=1):
marker = image.anchor._from
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}"
path = target / f"{ws.title}_{cell}_{number}.{image.format}"
path.write_bytes(image._data())
found.append({"sheet": ws.title, "cell": cell, "file": path.name})
return found
print(extract_floating("catalog.xlsx", "floating_out"))
# [{'sheet': 'Products', 'cell': 'F1', 'file': 'Products_F1_1.png'}]
The anchor is the top-left cell the picture is attached to, which is what a person means by "the picture in F1". A picture that spans several cells still reports only its top-left corner. openpyxl needs Pillow installed to load images at all; without it, the images are silently skipped.
Step 3 — Map pictures in cells to their cells
Pictures placed in cells are invisible to openpyxl: the cell's value reads as #VALUE! and there is no image object. The link from cell to picture runs
through four small XML parts. The cell carries a vm attribute (value metadata index, one-based); xl/metadata.xml maps that to a rich value index;
xl/richData/rdrichvalue.xml gives the relationship index for that rich value; and xl/richData/richValueRel.xml with its .rels file resolves that to a
media file. Following the chain with xml.etree is short.
import re
import xml.etree.ElementTree as ET
NS = {
"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"rd": "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata",
"rv": "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"pr": "http://schemas.openxmlformats.org/package/2006/relationships",
}
def in_cell_image_map(xlsx: str, sheet_part: str = "xl/worksheets/sheet1.xml") -> dict[str, str]:
with zipfile.ZipFile(xlsx) as z:
names = set(z.namelist())
if "xl/richData/rdrichvalue.xml" not in names:
return {}
meta = ET.fromstring(z.read("xl/metadata.xml"))
future = [bk.find(".//rd:rvb", NS).get("i") for bk in meta.find("m:futureMetadata", NS).findall("m:bk", NS)]
value_meta = [int(bk.find("m:rc", NS).get("v")) for bk in meta.find("m:valueMetadata", NS).findall("m:bk", NS)]
structures = ET.fromstring(z.read("xl/richData/rdrichvaluestructure.xml")).findall("rd:s", NS)
values = ET.fromstring(z.read("xl/richData/rdrichvalue.xml")).findall("rd:rv", NS)
rel_ids = [rel.get(f"{{{NS['r']}}}id") for rel in ET.fromstring(z.read("xl/richData/richValueRel.xml"))]
targets = {rel.get("Id"): rel.get("Target")
for rel in ET.fromstring(z.read("xl/richData/_rels/richValueRel.xml.rels")).findall("pr:Relationship", NS)}
sheet_xml = z.read(sheet_part).decode("utf-8")
mapping = {}
for ref, vm in re.findall(r'<c r="([A-Z]+[0-9]+)"[^>]*\bvm="([0-9]+)"', sheet_xml):
rich_index = int(future[value_meta[int(vm) - 1]])
rich_value = values[rich_index]
keys = [k.get("n") for k in structures[int(rich_value.get("s"))].findall("rd:k", NS)]
rel_position = int(rich_value.findall("rd:v", NS)[keys.index("_rvRel:LocalImageIdentifier")].text)
mapping[ref] = "xl/" + targets[rel_ids[rel_position]].replace("../", "")
return mapping
print(in_cell_image_map("catalog.xlsx"))
# {'C2': 'xl/media/image1.png', 'C3': 'xl/media/image2.png'}
Notice that the media file numbers say nothing about position: here the in-cell pictures took image1 and image2 and the floating logo
took image3, purely because of the order in which the writing library stored them. Another application saving the same workbook could
renumber everything. That is the whole reason to follow the chain from the cell rather than guessing from file names.
The lookup of _rvRel:LocalImageIdentifier by name in the value's structure, rather than assuming it is the first value, keeps the function working for
files saved by Excel as well as by xlsxwriter, since the order of keys in a structure is not guaranteed. The sheet_part argument names the worksheet's XML
part; for workbooks with several sheets, resolve each sheet's part from xl/_rels/workbook.xml.rels.
Step 4 — Save files named after the row they belong to
With a map from cell to media path, extraction can name each file after something meaningful in the same row — a SKU, an employee ID, a receipt number — which is what a downstream import needs.
def extract_by_key(xlsx: str, key_column: str, out_dir: str) -> list[str]:
target = Path(out_dir)
target.mkdir(parents=True, exist_ok=True)
sheet = load_workbook(xlsx).worksheets[0]
written = []
with zipfile.ZipFile(xlsx) as z:
for ref, media in in_cell_image_map(xlsx).items():
row = int(re.sub(r"[A-Z]+", "", ref))
key = sheet[f"{key_column}{row}"].value or f"row{row}"
path = target / f"{key}{Path(media).suffix}"
path.write_bytes(z.read(media))
written.append(path.name)
return written
print(extract_by_key("catalog.xlsx", "A", "photos_by_sku"))
# ['W-100.png', 'G-200.png']
Sanitise the key before using it in a filename if it can contain slashes or other characters that paths do not allow. And check for duplicates: two rows with the same SKU would overwrite each other's photo, which is itself a data problem worth reporting.
Step 5 — Verify what you extracted
Image files pulled from office documents are occasionally truncated, in an unexpected format, or far larger than they appear on screen. Open each with Pillow before handing the folder to another system, and record the dimensions alongside the key.
from PIL import Image
def audit(folder: str) -> list[dict]:
report = []
for path in sorted(Path(folder).iterdir()):
with Image.open(path) as picture:
picture.verify()
with Image.open(path) as picture:
report.append({"file": path.name, "format": picture.format, "size": picture.size})
return report
for entry in audit("photos_by_sku"):
print(entry)
verify() detects corrupt files without decoding the whole image, but leaves the image object unusable, which is why the file is opened a second time to read
its size.
The audit is also the natural place to compare against the sheet. Count the rows that should have a picture — every product with a SKU, every expense line above a threshold — and compare with the number of files written. A catalogue where 212 of 220 products produced a photo tells you exactly which eight rows need attention, which is usually the real question behind "can you get the pictures out of this spreadsheet?". Writing the missing keys to a small CSV next to the image folder gives whoever owns the workbook a precise list to fix, instead of a vague report that some photos are missing.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
ws._images is empty | Pillow not installed, or pictures are in cells | Install Pillow; use the rich value map for in-cell pictures |
| Fewer files than pictures on screen | The same image is reused in several places | Use anchors or the cell map to see every placement |
KeyError: xl/metadata.xml | Workbook has no pictures in cells | Check for xl/richData/ first, as the function does |
| Wrong row for a floating picture | Anchor is the top-left corner only | Treat the anchor as the picture's owning cell |
Extracted images are .emf or .wmf | Pasted from older Office content | Convert with a tool such as LibreOffice or Inkscape |
.xls files yield nothing | Legacy binary format, not a zip | Convert to .xlsx first |
Performance and scale notes
Reading from the zip archive is fast regardless of workbook size, because the media files are read directly without parsing any cell data. The openpyxl route loads
every cell of every sheet, which is slow for large workbooks when all you want is a handful of pictures. For a big workbook with in-cell pictures, the rich value
map above avoids openpyxl for the images entirely — use a read_only openpyxl load, or pandas, just to read the key column.
Conclusion
Every picture in an .xlsx sits in xl/media/, so a zip read extracts the files instantly. When the position matters, use openpyxl's image anchors for floating
pictures, and follow the cell's vm attribute through metadata.xml and the rich value parts for pictures placed in cells, which openpyxl does not load. Name the
extracted files after a key from the same row, check for duplicate keys, and verify each image with Pillow before passing the folder on.
Frequently asked questions
Can pandas extract images from Excel? No. pandas reads cell values only; picture cells come back as missing values. Use zipfile or openpyxl as shown on this page.
Does this work for charts? Charts are not images; they are stored as chart XML parts and drawn by Excel. To get a chart as a picture, export it from Excel, or regenerate it with matplotlib from the underlying data.
How do I extract images from every sheet? For floating pictures the openpyxl loop already covers all worksheets. For pictures in cells, map each sheet name to its XML part through xl/workbook.xml and xl/_rels/workbook.xml.rels, then call the map function per part.
Why do some extracted images have a different resolution from what I see? Excel scales pictures for display but stores the original. The extracted file is the stored original, which is usually the better version to keep.
Related
- Up one level: Inserting Images and Logos into Excel — adding pictures to workbooks.
- Embed Images Inside Cells with xlsxwriter — how pictures in cells are written.
- Resize and Position Images in Excel with openpyxl — the anchors read in Step 2.
- Fix BadZipFile When Reading an Excel File in Python — when the archive itself is damaged.
- Read Cell Colours and Font Styles as Data with openpyxl — other information stored outside cell values.
More on charts & images
- How-toBuild a Dashboard Sheet with Charts from Multiple Tabs
- How-toBuild a Rolling 12-Month Chart on an Excel Dashboard
- How-toKeep Charts and Images When Filling an Excel Template
- How-toAdd a Chart to an Excel File with xlsxwriter
- How-toAdd Sparklines to an Excel Report with xlsxwriter
- How-toBuild a Waterfall Chart in Excel with Python