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.

The two halves of a picture in a DOCX The drawing element sits in a run in the document body and carries the extent, the wrapping mode, the anchoring and any effects the designer applied. It references an image by relationship id. The relationship maps that id to a part under word media. The part holds the actual image bytes. Replacing the part's bytes changes the picture while leaving every layout decision untouched. Deleting the run destroys the drawing element and all of it. Drawing element in a run position, size, wrapping destroyed if you clear the run Relationship id, e.g. rId7 the link between the two stays stable Image part, word/media/image1.png the pixels what you actually swap Replace the part's bytes layout is untouched the safe operation

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.

Choosing a replacement technique If the designer's position, size and wrapping must be preserved exactly, replace the image part's bytes and change nothing else. If the template is a docxtpl template and the image is a placeholder tag, use InlineImage, which lets each document choose its own size. If the same placeholder is used in several places with different sizes, give each its own image part so they can differ. If there is no placeholder at all, insert a new picture and accept that the position comes from the code. Must the designer's layout survive? that decides the technique yes, exactly Swap the part bytes layout untouched docxtpl template InlineImage tag size chosen per document same logo Separate image parts independent replacement no placeholder Insert a new picture position comes from code

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.

What goes wrong and what it looks like Clearing the run destroys the position and wrapping, and the image appears at the left margin at native size. Ignoring the aspect ratio stretches a logo that does not match the placeholder shape. Handling only the main header leaves the old logo on first pages. Saving a transparent PNG as JPEG produces a black background. Not resizing before embedding produces an enormous file. Each has a one line correction. Mistake Symptom Fix Clear the run first loses position and size swap bytes only Ignore aspect ratio logo stretched pad to the old ratio Only section.header old logo on page 1 all six header parts RGBA saved as JPEG black background keep PNG, or flatten Embed full-size photo 100MB document resize to 200 DPI Hard-code rId breaks on template edit match on part name

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.

Part of Inserting Images into Word Documents.