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.
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.
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.
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.
Related
- Inserting Images into Word Documents — the image workflow this belongs to
- Fix Image Too Large in python-docx — sizing and aspect-ratio control
- Add Headers and Footers with python-docx — placing a logo image in the header
- Automating Word Document Creation — building the document around the photo
- Converting DOCX to PDF with Python — confirming the image survives rendering
Part of Inserting Images into Word Documents.