Insert Images into Table Cells with python-docx
Building a product catalogue or an inspection report means a picture in every row. The obvious call puts it somewhere else entirely:
table = doc.add_table(rows=3, cols=2)
for row, (name, photo) in zip(table.rows, products):
row.cells[0].text = name
doc.add_picture(photo, width=Cm(4)) # lands after the table, not in the cell
Every image appears stacked below the table. Moving to cell.add_paragraph().add_run().add_picture(...) puts them in the right place but leaves a blank line above each one, and any image wider than the column silently stretches the whole table past the page margin.
Root Cause
Document.add_picture is a convenience that appends a paragraph to the document body and puts the picture in it — it has no notion of the cell being worked on. Pictures live in runs, so placing one inside a cell means reaching a run inside one of that cell's paragraphs. That is where the second problem starts: every table cell is created containing exactly one empty paragraph, so calling cell.add_paragraph() adds a second one and the first renders as a blank line above the image. The third problem is sizing. A picture inserted without a width uses its native pixel dimensions at 96 DPI, so a 1500-pixel photo becomes about 40cm wide; with the table set to autofit, Word widens the column to match and pushes the table off the page.
Minimal Diagnostic
Report where the pictures actually ended up and how wide they are.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Emu
DOCX = Path("out/catalogue.docx")
def picture_report(path: Path) -> None:
doc = Document(path)
body_pictures = sum(len(p.runs and [r for r in p.runs if r._r.findall(
".//{http://schemas.openxmlformats.org/drawingml/2006/main}blip")]) for p in doc.paragraphs)
print(f"pictures in the document body: {body_pictures}")
for table_index, table in enumerate(doc.tables):
for row_index, row in enumerate(table.rows):
for column_index, cell in enumerate(row.cells):
shapes = cell._tc.findall(
".//{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}inline")
blanks = sum(1 for p in cell.paragraphs if not p.text.strip() and not p.runs)
if shapes or blanks:
widths = []
for inline in shapes:
extent = inline.find(
"{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}extent")
widths.append(f"{Emu(int(extent.get('cx'))).cm:.1f}cm" if extent is not None else "?")
print(f" table {table_index} r{row_index}c{column_index}: "
f"{len(shapes)} picture(s) {widths}, {blanks} blank paragraph(s), "
f"{len(cell.paragraphs)} paragraph(s)")
if __name__ == "__main__":
picture_report(DOCX)
pictures in the document body: 3
table 0 r0c1: 0 picture(s) [], 1 blank paragraph(s), 1 paragraph(s)
table 0 r1c1: 0 picture(s) [], 1 blank paragraph(s), 1 paragraph(s)
Three pictures in the body, none in the cells: the exact signature of doc.add_picture inside a cell loop.
Fix: Reuse the Cell's First Paragraph
Reach the cell's existing paragraph, add a run to it, and size the picture against the column width.
# pip install python-docx pillow
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Cm, Length
from PIL import Image
def cell_paragraph(cell):
"""The cell's first paragraph, reused if it is empty."""
paragraph = cell.paragraphs[0]
if paragraph.text or paragraph.runs:
paragraph = cell.add_paragraph() # only add when the first is in use
return paragraph # changed: no leading blank line
def fitted_size(image_path: Path, max_width: Length, max_height: Length | None = None):
with Image.open(image_path) as image:
pixel_width, pixel_height = image.size
ratio = pixel_height / pixel_width
width = max_width
height = Cm(width.cm * ratio)
if max_height is not None and height.cm > max_height.cm: # changed: fit both dimensions
height = max_height
width = Cm(height.cm / ratio)
return width, height
def add_picture_to_cell(cell, image_path: Path, max_width: Length,
max_height: Length | None = None, centre: bool = True) -> None:
paragraph = cell_paragraph(cell)
if centre:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
width, height = fitted_size(image_path, max_width, max_height)
run = paragraph.add_run() # changed: pictures live in runs
run.add_picture(str(image_path), width=width, height=height)
if __name__ == "__main__":
products = [("Ledger scanner A2", Path("img/a2.png")),
("Batch feeder C40", Path("img/c40.png")),
("Archive shelf unit", Path("img/shelf.png"))]
doc = Document()
table = doc.add_table(rows=len(products), cols=2, style="Table Grid")
table.autofit = False
text_width, image_width = Cm(9), Cm(5.5)
for row, (name, image_path) in zip(table.rows, products):
for cell, width in zip(row.cells, (text_width, image_width)):
cell.width = width # changed: fix the column before sizing images
row.cells[0].text = name
add_picture_to_cell(row.cells[1], image_path,
max_width=image_width - Cm(0.5), # changed: allow for cell padding
max_height=Cm(4))
Path("out").mkdir(exist_ok=True)
doc.save("out/catalogue.docx")
Subtracting half a centimetre from the column width accounts for Word's default cell padding of 0.19cm per side; a picture exactly as wide as the column overflows it and widens the table. Passing both width and height from fitted_size keeps the aspect ratio under control — passing only one lets python-docx scale the other, which is fine until an image arrives with a non-square pixel aspect and the result looks subtly stretched.
Variant Fix 1: Sizing Against the Real Column Width
Hard-coding the column width works until the table changes. Read it from the cell instead:
# pip install python-docx
from docx.oxml.ns import qn
from docx.shared import Cm, Emu
CELL_PADDING = Cm(0.19)
def usable_width(cell, fallback: Cm = Cm(5)) -> Cm:
tc_pr = cell._tc.tcPr
tc_w = tc_pr.find(qn("w:tcW")) if tc_pr is not None else None
if tc_w is None or tc_w.get(qn("w:type")) != "dxa":
return fallback # autofit or percentage: no fixed width to read
return Cm(Emu(int(tc_w.get(qn("w:w")))).cm - 2 * CELL_PADDING.cm)
Returning a fallback rather than raising matters because a cell with no explicit width is the normal state of an autofit table — the width simply does not exist yet. Setting the widths first, as described in column width ignored, is what makes this function useful.
Variant Fix 2: Missing and Broken Image Files
In a catalogue of four hundred products, some images will be missing, truncated or in a format Word cannot embed. Handle that per cell rather than letting the whole job fail:
# pip install python-docx pillow
from pathlib import Path
from PIL import Image, UnidentifiedImageError
USABLE = {"PNG", "JPEG", "GIF", "BMP", "TIFF"}
def add_picture_or_note(cell, image_path: Path, max_width, max_height=None) -> str:
if not image_path.exists():
cell.text = "[image not supplied]"
return "missing"
try:
with Image.open(image_path) as image:
image.verify() # catches truncated files
fmt = image.format
except (UnidentifiedImageError, OSError) as error:
cell.text = f"[unreadable image: {type(error).__name__}]"
return "unreadable"
if fmt not in USABLE:
cell.text = f"[unsupported format: {fmt}]"
return "unsupported"
add_picture_to_cell(cell, image_path, max_width, max_height)
return "ok"
Writing the reason into the cell is deliberate. A blank cell in a four-hundred-row catalogue is indistinguishable from a product that has no photo; a cell reading [image not supplied] tells whoever reviews the document exactly what to chase. Returning a status lets the caller count the outcomes and fail the job if too many rows are incomplete.
Keeping the File Size Sensible
python-docx embeds each image file exactly as supplied. A catalogue with four hundred 4-megapixel photographs produces a document of several hundred megabytes that Word opens slowly and email rejects. Downscaling before insertion costs a few lines and usually removes ninety percent of the size:
# pip install pillow
import io
from pathlib import Path
from PIL import Image
def prepared(image_path: Path, target_cm: float, dpi: int = 200) -> io.BytesIO:
target_pixels = int(target_cm / 2.54 * dpi)
with Image.open(image_path) as image:
image = image.convert("RGB") if image.mode in ("RGBA", "P") else image
if image.width > target_pixels:
ratio = target_pixels / image.width
image = image.resize((target_pixels, int(image.height * ratio)), Image.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=85, optimize=True)
buffer.seek(0)
return buffer
run.add_picture accepts a file-like object, so the buffer goes straight in without a temporary file. Two hundred DPI is the useful ceiling for a document that may be printed; ninety-six is enough for one that will only be read on screen. Converting RGBA to RGB avoids the black-background artefact that appears when a transparent PNG is saved as JPEG — keep PNG for logos and line art where transparency matters.
Verification
Confirm the pictures are in the cells, sized within the columns, and that no blank paragraphs crept in.
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Emu
NS_WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
def verify_cell_images(path: Path, expected: int, max_width_cm: float) -> None:
doc = Document(path)
body = [p for p in doc.paragraphs if p._p.findall(f".//{NS_WP}inline")]
assert not body, f"{len(body)} picture(s) in the document body, expected all in cells"
found, oversized, blanks = 0, [], []
for table in doc.tables:
for row_index, row in enumerate(table.rows):
for column_index, cell in enumerate(row.cells):
inlines = cell._tc.findall(f".//{NS_WP}inline")
found += len(inlines)
for inline in inlines:
extent = inline.find(f"{NS_WP}extent")
width_cm = Emu(int(extent.get("cx"))).cm
if width_cm > max_width_cm + 0.01:
oversized.append(f"r{row_index}c{column_index}={width_cm:.2f}cm")
if inlines and len([p for p in cell.paragraphs if not p.text and not p.runs]):
blanks.append(f"r{row_index}c{column_index}")
assert found == expected, f"{found} picture(s) in cells, expected {expected}"
assert not oversized, f"picture(s) wider than the column: {oversized}"
assert not blanks, f"blank paragraph(s) beside a picture at {blanks}"
print(f"{path.name}: {found} picture(s) in cells, none oversized, no blank lines")
if __name__ == "__main__":
verify_cell_images(Path("out/catalogue.docx"), expected=3, max_width_cm=5.0)
The body-picture assertion is the one that catches the original bug, and it is worth keeping even after the code is correct: it fails immediately if someone adds a figure with doc.add_picture inside a cell loop again.
FAQ
Can I put text and an image in the same cell? Yes — set the first paragraph's text, then add a second paragraph for the picture. The helper does this automatically when the first paragraph is in use.
Why is my row much taller than the image?
Usually a blank paragraph below the picture, or a row height set explicitly. Check len(cell.paragraphs).
Does the image keep its aspect ratio?
Only if one dimension is given, or both are computed from the source as fitted_size does. Setting both to arbitrary values stretches it.
Can I anchor an image behind the text? Not through python-docx's API — floating pictures need direct XML. Inline pictures in cells cover nearly every report layout.
Related
- Inserting Images into Word Documents — the image workflow end to end
- Replace Placeholder Images in DOCX Templates — swapping images in an existing template
- Fix python-docx Table Column Width Ignored — fixing the columns before sizing images
- Building and Editing Word Tables — the table workflow
Part of Inserting Images into Word Documents.