Replace Placeholder Images in DOCX Templates
A designer supplied a letterhead template with a placeholder logo positioned exactly where it belongs. The job is to swap in each customer's logo per document, keeping the position, and the usual approaches all lose something:
doc = Document("templates/letterhead.docx")
doc.paragraphs[0].runs[0].clear() # removes the picture
doc.paragraphs[0].runs[0].add_picture("logo.png") # ...and its position, wrapping and anchoring
The new image appears at the left margin at its native size, the header's careful spacing is gone, and any text wrapping the designer set has reverted to inline.
Root Cause
A picture in a DOCX is two separate things: a drawing element in the document body that carries position, size, wrapping and anchoring, and an image part — the actual PNG or JPEG bytes — stored under word/media/ and referenced by a relationship id. Deleting the run destroys the drawing element, which is where all the layout lives; the replacement then gets python-docx's defaults. The efficient fix keeps the drawing element exactly as the designer left it and replaces only the bytes behind the relationship. The size stays because the drawing's extent is unchanged, which also means the new image is stretched to the old one's dimensions unless its aspect ratio matches.
Minimal Diagnostic
List every image in the template with its relationship id, stored size and rendered size.
# pip install python-docx pillow
import io
from pathlib import Path
from docx import Document
from docx.shared import Emu
from PIL import Image
TEMPLATE = Path("templates/letterhead.docx")
NS_A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
NS_WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
def image_report(path: Path) -> None:
doc = Document(path)
parts = {rel_id: rel.target_part for rel_id, rel in doc.part.rels.items()
if "image" in rel.reltype}
for rel_id, part in sorted(parts.items()):
blob = part.blob
with Image.open(io.BytesIO(blob)) as image:
print(f" {rel_id}: {part.partname} {image.format} {image.size[0]}x{image.size[1]}px "
f"{len(blob) / 1024:.0f}KB")
for section_index, section in enumerate(doc.sections):
header_images = section.header.part.rels if section.header else {}
for rel_id, rel in header_images.items():
if "image" in rel.reltype:
print(f" header {section_index}: {rel_id} -> {rel.target_part.partname}")
for blip in doc.element.body.iter(f"{NS_A}blip"):
embed = blip.get(f"{NS_A.strip('{}')}".join([]) or
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed")
drawing = blip.getparent().getparent().getparent().getparent()
extent = drawing.find(f"{NS_WP}extent") if drawing is not None else None
if extent is not None:
print(f" used as {embed}: rendered {Emu(int(extent.get('cx'))).cm:.2f}cm wide")
if __name__ == "__main__":
image_report(TEMPLATE)
rId4: /word/media/image1.png PNG 1200x400px 184KB
rId5: /word/media/image2.png PNG 600x200px 41KB
header 0: rId4 -> /word/media/image1.png
used as rId5: rendered 4.50cm wide
Two images: one in the header, one in the body, each with a stable relationship id. Those ids are what the replacement targets.
Fix: Replace the Image Part's Bytes
Write new bytes into the existing part. Everything about the layout stays as it was.
# pip install python-docx pillow
import io
from pathlib import Path
from docx import Document
from docx.parts.image import ImagePart
from PIL import Image
def image_parts(doc) -> dict[str, ImagePart]:
"""Every image part reachable from the document, its headers and its footers."""
parts = {}
containers = [doc.part] + [s.header.part for s in doc.sections if s.header] \
+ [s.footer.part for s in doc.sections if s.footer]
for container in containers:
for rel_id, rel in container.rels.items():
if "image" in rel.reltype:
parts[f"{container.partname}|{rel_id}"] = rel.target_part
return parts
def match_aspect(new_image: Path, old_bytes: bytes) -> io.BytesIO:
"""Pad the new image onto the old one's aspect ratio so nothing is stretched."""
with Image.open(io.BytesIO(old_bytes)) as old:
target_ratio = old.width / old.height
target_format = old.format or "PNG"
with Image.open(new_image) as new:
new = new.convert("RGBA")
ratio = new.width / new.height
if abs(ratio - target_ratio) < 0.01:
canvas = new
elif ratio > target_ratio: # too wide: pad top and bottom
height = int(new.width / target_ratio)
canvas = Image.new("RGBA", (new.width, height), (255, 255, 255, 0))
canvas.paste(new, (0, (height - new.height) // 2))
else: # too tall: pad left and right
width = int(new.height * target_ratio)
canvas = Image.new("RGBA", (width, new.height), (255, 255, 255, 0))
canvas.paste(new, ((width - new.width) // 2, 0))
buffer = io.BytesIO()
if target_format == "JPEG":
canvas = canvas.convert("RGB")
canvas.save(buffer, format=target_format)
return buffer
def replace_image(docx: Path, out: Path, target_partname: str, new_image: Path) -> None:
doc = Document(docx)
for key, part in image_parts(doc).items():
if not key.endswith(target_partname) and target_partname not in str(part.partname):
continue
buffer = match_aspect(new_image, part.blob) # changed: no stretching
part._blob = buffer.getvalue() # changed: swap the bytes only
break
else:
raise KeyError(f"no image part matching {target_partname!r}")
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(out)
if __name__ == "__main__":
replace_image(Path("templates/letterhead.docx"), Path("out/letter-northgate.docx"),
target_partname="image1.png", new_image=Path("logos/northgate.png"))
Padding to the original aspect ratio is what makes this safe to run on logos that arrive in every shape. Without it, a square logo dropped into a 3:1 placeholder is stretched to three times its width, which looks like a bug in the template rather than in the data. Padding with transparency keeps the logo's own proportions and lets the letterhead background show through.
Variant Fix 1: docxtpl's InlineImage
When the template is already a docxtpl template, a placeholder tag is cleaner than byte surgery:
# pip install docxtpl
from pathlib import Path
from docxtpl import DocxTemplate, InlineImage
from docx.shared import Cm
def render_with_logo(template_path: Path, out: Path, context: dict, logo: Path) -> Path:
template = DocxTemplate(template_path)
context = dict(context)
context["customer_logo"] = InlineImage(template, str(logo), width=Cm(4)) # height follows the ratio
template.render(context)
template.save(out)
return out
The template contains {{ customer_logo }} where the image belongs, and the size is decided in code rather than by the placeholder. Giving only a width lets the height follow the source ratio, so nothing stretches — the trade against byte replacement is that position and wrapping come from wherever the tag sits in the document, not from a designer-placed frame.
Variant Fix 2: Headers, Footers and Repeated Logos
A logo in the header is a different part from the same logo in the body, and Document.part.rels does not reach it:
# pip install python-docx
from pathlib import Path
from docx import Document
def replace_everywhere(docx: Path, out: Path, old_partname: str, new_bytes: bytes) -> int:
doc = Document(docx)
replaced = 0
containers = [doc.part]
for section in doc.sections:
for holder in (section.header, section.first_page_header, section.even_page_header,
section.footer, section.first_page_footer, section.even_page_footer):
if holder is not None:
containers.append(holder.part)
for container in containers:
for rel in container.rels.values():
if "image" in rel.reltype and old_partname in str(rel.target_part.partname):
rel.target_part._blob = new_bytes
replaced += 1
doc.save(out)
return replaced
Word supports different headers for the first page, odd pages and even pages, and a template using all three has three header parts. A replacement that only handles section.header leaves the old logo on page one of every letter — the page recipients actually look at.
If Word deduplicated the image, several relationships point at one part and replacing it once changes all of them, which is usually what is wanted. When it is not — a large logo on the cover and a small one in the footer — the template needs two genuinely different image files so Word stores two parts.
Why Not Edit the Zip Directly
It is tempting to treat the DOCX as a zip, overwrite word/media/image1.png and rezip. It works often enough to be dangerous. The content-types part declares a type per extension, so replacing a PNG with a JPEG under the same name produces a file Word repairs on open, discarding the image. Rewriting the archive also loses the original entry order and compression settings, which some downstream tools depend on, and any file with a different name becomes an orphan part that validators flag. Going through python-docx keeps the package consistent for a few lines more code, and the diagnostic above gives the part name to target without ever unzipping anything.
Verification
Check the replacement landed, the layout is unchanged, and no stretching was introduced.
# pip install python-docx pillow
import io
from pathlib import Path
from docx import Document
from docx.shared import Emu
from PIL import Image
NS_WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
def verify_replacement(original: Path, updated: Path, expect_bytes: Path, tolerance_cm=0.01) -> None:
before = Document(original)
after = Document(updated)
extents = []
for doc in (before, after):
sizes = [(Emu(int(e.get("cx"))).cm, Emu(int(e.get("cy"))).cm)
for e in doc.element.body.iter(f"{NS_WP}extent")]
extents.append(sizes)
assert len(extents[0]) == len(extents[1]), "picture count changed"
for index, (old_size, new_size) in enumerate(zip(*extents)):
assert abs(old_size[0] - new_size[0]) < tolerance_cm and \
abs(old_size[1] - new_size[1]) < tolerance_cm, \
f"picture {index} resized: {old_size} -> {new_size}"
blobs = {rel.target_part.blob for rel in after.part.rels.values() if "image" in rel.reltype}
with Image.open(expect_bytes) as expected:
expected_ratio = expected.width / expected.height
for blob in blobs:
with Image.open(io.BytesIO(blob)) as image:
assert image.width > 0 and image.height > 0, "empty image part"
print(f"{updated.name}: {len(extents[1])} picture(s), geometry unchanged, "
f"new logo ratio {expected_ratio:.2f}")
if __name__ == "__main__":
verify_replacement(Path("templates/letterhead.docx"), Path("out/letter-northgate.docx"),
Path("logos/northgate.png"))
Comparing the extents before and after is the assertion that proves the layout survived. It passes trivially when the code is right and fails loudly the moment someone reverts to clearing the run, which is the regression this whole page exists to prevent.
FAQ
Will replacing the bytes break the image if the format differs?
Yes if the extension no longer matches the content type. Save the replacement in the original's format, as match_aspect does.
How do I find which part is the logo when there are many? Run the diagnostic and match on size or dimensions; a 1200x400 PNG in the header is usually the letterhead.
Can I replace an image inside a table cell this way? Yes — the part is the same regardless of where the drawing sits. See images in table cells.
Does this work on a template rendered by docxtpl?
Replace after rendering, or use InlineImage instead. Doing both to the same picture is what produces a duplicated logo.
Related
- Inserting Images into Word Documents — the image workflow end to end
- Insert Images into Table Cells with python-docx — placing new images in tables
- Dynamic Mail Merge with Python — per-customer documents from one template
- Converting DOCX to PDF with Python — delivering the finished letterhead
Part of Inserting Images into Word Documents.