Fix Images Rotated After Insert in python-docx

The photo looks upright everywhere — the file manager, the phone, the browser — and lands sideways in the generated document:

document.add_picture("data/site_photo.jpg", width=Cm(12))
# Word shows it rotated 90 degrees, and stretched to a landscape box

Nothing rotated it. The image was always stored sideways, with a metadata tag asking viewers to turn it — and python-docx embeds the pixels as they are, tag and all, while Word ignores the tag.

Root Cause

Phone cameras have a fixed sensor. Rather than rewriting pixel data when the phone is held vertically, the camera writes the frame in its native orientation and records an EXIF Orientation tag — a number from 1 to 8 — describing the rotation and mirroring a viewer should apply.

Most viewers honour it. Word does not: it renders the stored pixels. python-docx embeds the file bytes unchanged, so the tag travels into the document and is ignored there. Worse, the dimensions python-docx reads are the stored ones, so a portrait photo tagged for rotation is treated as landscape and any aspect-ratio calculation based on it is wrong too.

Stored pixels against displayed orientation A JPEG holds landscape pixels plus an EXIF orientation value of six, meaning rotate ninety degrees clockwise. An image viewer applies the tag and shows an upright portrait photo. Word ignores the tag and shows the landscape pixels, so the photo appears sideways in the document. Rewriting the file with the rotation baked into the pixels and the tag cleared makes both render identically. stored pixels 4032 x 3024 EXIF Orientation = 6 "rotate 90° clockwise" upright viewer: applies the tag sideways Word: ignores the tag normalise rotated pixels 3024 x 4032 tag cleared upright everywhere Normalising also fixes the dimensions, so width and height calculations stop being transposed

Minimal Diagnostic

Read the tag before inserting anything. One number tells you whether the file needs work.

# pip install pillow
from pathlib import Path
from PIL import Image, ExifTags

PHOTO = Path("data/site_photo.jpg")

ORIENTATION_MEANING = {
    1: "normal — no action needed",
    2: "mirrored horizontally",
    3: "rotated 180°",
    4: "mirrored vertically",
    5: "mirrored and rotated 90° counter-clockwise",
    6: "rotated 90° clockwise",
    7: "mirrored and rotated 90° clockwise",
    8: "rotated 90° counter-clockwise",
}

def orientation_report(image_path: Path) -> dict:
    """Report the stored size and any EXIF orientation the file carries."""
    if not image_path.exists():
        raise FileNotFoundError(f"No such image: {image_path}")
    try:
        with Image.open(image_path) as image:
            exif = image.getexif() or {}
            tag = next((k for k, v in ExifTags.TAGS.items() if v == "Orientation"), 274)
            value = int(exif.get(tag, 1))
            return {
                "stored_size": image.size,                 # what Word will use
                "orientation": value,
                "meaning": ORIENTATION_MEANING.get(value, "unknown"),
                "needs_normalising": value != 1,
            }
    except Exception as exc:
        raise RuntimeError(f"Could not read {image_path}: {exc}") from exc

if __name__ == "__main__":
    print(orientation_report(PHOTO))
{'stored_size': (4032, 3024), 'orientation': 6,
 'meaning': 'rotated 90° clockwise', 'needs_normalising': True}

Anything other than 1 will render wrongly in Word. Values 2, 4, 5 and 7 also mirror, which is rarer from phones but appears in scanned batches.

The Fix: Bake the Rotation Into the Pixels

Pillow's ImageOps.exif_transpose applies the tag and returns an image whose pixels need no further interpretation.

# pip install pillow "python-docx>=1.1,<2"
import tempfile
from pathlib import Path

from PIL import Image, ImageOps
from docx import Document
from docx.shared import Cm

def normalise_image(src: Path, dest: Path | None = None, quality: int = 90) -> Path:
    """Apply EXIF orientation to the pixels and drop the tag."""
    if not src.exists():
        raise FileNotFoundError(f"No such image: {src}")
    if dest is None:
        dest = Path(tempfile.mkdtemp(prefix="docx-img-")) / src.name

    with Image.open(src) as image:
        upright = ImageOps.exif_transpose(image)      # rotates and mirrors as the tag asks
        if upright.mode in ("RGBA", "P") and dest.suffix.lower() in {".jpg", ".jpeg"}:
            upright = upright.convert("RGB")          # JPEG cannot store an alpha channel
        dest.parent.mkdir(parents=True, exist_ok=True)
        # Saving without the exif argument writes no orientation tag at all
        upright.save(dest, quality=quality, optimize=True)
    return dest

def add_photo(document: Document, src: Path, width_cm: float = 12.0) -> None:
    """Insert a photo upright, sized by width so the aspect ratio is preserved."""
    upright = normalise_image(src)
    document.add_picture(str(upright), width=Cm(width_cm))

if __name__ == "__main__":
    doc = Document()
    doc.add_heading("Site inspection", level=1)
    add_photo(doc, Path("data/site_photo.jpg"))
    Path("out").mkdir(exist_ok=True)
    doc.save("out/inspection.docx")
    print("Wrote out/inspection.docx")

Two details keep this correct. Saving without passing exif= writes a file with no orientation tag, so nothing downstream can re-apply a rotation that is now already in the pixels. And specifying only width lets python-docx scale the height proportionally from the normalised dimensions — passing both width and height is what produces the stretched look, as covered in fix image too large in python-docx.

Variant Fix 1: Choosing Width or Height by Shape

After normalising, a portrait photo given a fixed width can run off the bottom of the page. Pick the constrained dimension from the aspect ratio.

# pip install pillow "python-docx>=1.1,<2"
from pathlib import Path
from PIL import Image
from docx.shared import Cm

def add_photo_fitted(document, src: Path, max_width_cm: float = 12.0,
                     max_height_cm: float = 16.0) -> None:
    """Insert an image scaled to fit inside a box, whichever way it is oriented."""
    upright = normalise_image(src)
    with Image.open(upright) as image:
        width_px, height_px = image.size

    if width_px / height_px >= max_width_cm / max_height_cm:
        document.add_picture(str(upright), width=Cm(max_width_cm))     # width-limited
    else:
        document.add_picture(str(upright), height=Cm(max_height_cm))   # height-limited

Comparing the two aspect ratios rather than checking width > height is what makes this correct for near-square images, where a naive test flips between branches on a few pixels.

Variant Fix 2: A Whole Folder of Photos

Batch jobs should normalise once, into a cache, rather than per document — the same photo often appears in several reports.

# pip install pillow
from pathlib import Path

def normalise_folder(src_dir: Path, cache_dir: Path,
                     suffixes: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".tif")) -> dict[str, Path]:
    """Normalise every image once and return a name-to-path map."""
    if not src_dir.is_dir():
        raise NotADirectoryError(f"Not a directory: {src_dir}")
    cache_dir.mkdir(parents=True, exist_ok=True)

    mapping: dict[str, Path] = {}
    for path in sorted(src_dir.iterdir()):
        if path.suffix.lower() not in suffixes:
            continue
        target = cache_dir / path.name
        if target.exists() and target.stat().st_mtime >= path.stat().st_mtime:
            mapping[path.name] = target          # cache hit
            continue
        try:
            mapping[path.name] = normalise_image(path, target)
        except Exception as exc:
            print(f"skipping {path.name}: {exc}")
    return mapping

if __name__ == "__main__":
    images = normalise_folder(Path("data/photos"), Path("out/photo_cache"))
    print(f"{len(images)} image(s) ready")

Skipping rather than raising on a corrupt image matters in a 2,000-photo run, and printing the name preserves the evidence — the same tolerate-and-report posture used throughout automating document data pipelines.

Variant Fix 3: PNG and Screenshots

PNG has no EXIF orientation in practice, so a sideways PNG is genuinely stored sideways and needs an explicit rotation:

# pip install pillow
from PIL import Image

def rotate_fixed(src: Path, dest: Path, degrees: int = 90) -> Path:
    """Rotate by a known angle; expand=True keeps the whole image in frame."""
    with Image.open(src) as image:
        rotated = image.rotate(-degrees, expand=True)   # negative = clockwise
        rotated.save(dest)
    return dest

expand=True is not optional. Without it the canvas keeps its original dimensions and the rotated content is cropped to fit, which produces a photo with its edges cut off — a subtler failure than a sideways one and easier to ship by accident.

Where normalisation belongs in the pipeline Source photos pass through an EXIF check. Images with orientation one go straight to add_picture. Images with any other orientation are transposed by Pillow, written to a cache directory without an orientation tag, and then inserted. The cache means a photo used in several documents is normalised only once. source photo from a phone tag == 1? EXIF check YES — insert as is NO exif_transpose save to cache, no tag add_picture width only ratio preserved Caching normalised copies keeps a repeated photo from being re-encoded for every document

Verification

Assert on the embedded image: the document should contain an upright picture of the expected shape, and no orientation tag.

# pip install pillow "python-docx>=1.1,<2"
import io
from pathlib import Path
from PIL import Image, ExifTags
from docx import Document

def assert_images_upright(docx_path: Path, expect_portrait: bool | None = None) -> None:
    """Check every embedded image is stored upright with no orientation tag."""
    document = Document(str(docx_path))
    parts = [p for p in document.part.package.parts if p.content_type.startswith("image/")]
    assert parts, f"{docx_path.name} contains no images"

    tag_id = next((k for k, v in ExifTags.TAGS.items() if v == "Orientation"), 274)
    for part in parts:
        with Image.open(io.BytesIO(part.blob)) as image:
            orientation = int((image.getexif() or {}).get(tag_id, 1))
            assert orientation == 1, (
                f"{part.partname}: EXIF orientation {orientation} — normalise before inserting"
            )
            if expect_portrait is not None:
                is_portrait = image.height > image.width
                assert is_portrait == expect_portrait, (
                    f"{part.partname}: {image.size} is "
                    f"{'portrait' if is_portrait else 'landscape'}, expected the opposite"
                )
    print(f"{len(parts)} embedded image(s) verified upright")

if __name__ == "__main__":
    assert_images_upright(Path("out/inspection.docx"), expect_portrait=True)

Reading the images back out of the package rather than checking the source files is what makes this a real test: it proves the document carries normalised pixels, not merely that a normalisation function was called somewhere.

The eight orientation values at a glance Orientation one needs no action. Values three, six and eight are pure rotations of one hundred and eighty, ninety clockwise and ninety counter clockwise. Values two and four are pure mirrors. Values five and seven combine a mirror with a rotation. All seven non default values need normalising, and phone cameras produce six and eight most often. EXIF orientation values that need normalising 1 no action needed 3, 6, 8 rotation only — most common 2, 4 mirror only 5, 7 mirror + rotate exif_transpose handles all seven; a hand-rolled rotate handles only the middle group Phone cameras held vertically emit 6 or 8 almost exclusively

FAQ

Why does the photo look correct in my file browser but not in Word? The browser applies the EXIF orientation tag; Word renders the stored pixels. Both are showing the same file — only one of them is interpreting the metadata.

Does exif_transpose lose image quality? Rotating by multiples of 90 degrees is lossless at the pixel level, but re-encoding a JPEG is not. Save at quality 90 or above for photographs, or convert to PNG when the image is a screenshot or a diagram.

Can I strip the tag without rotating? You can, and the image will then be sideways everywhere instead of only in Word. Always transpose first, then save without the tag.

Do scanned TIFF files have this problem? Some do — scanners write orientation tags too, and multi-page TIFFs can carry a different tag per page. Normalise page by page, and confirm the page count survives.

Why did my image get cropped when I rotated it manually?Image.rotate keeps the original canvas unless expand=True is passed, so the corners fall outside the frame. Prefer exif_transpose for tag-driven rotation and reserve manual rotation for genuinely mis-stored images.

Part of Inserting Images into Word Documents.