Skip to content

Add support for image orientation - #110

Open
TheZ3ro wants to merge 1 commit into
StuffNoOneCaresAbout:masterfrom
TheZ3ro:image-rotation
Open

Add support for image orientation#110
TheZ3ro wants to merge 1 commit into
StuffNoOneCaresAbout:masterfrom
TheZ3ro:image-rotation

Conversation

@TheZ3ro

@TheZ3ro TheZ3ro commented Aug 4, 2026

Copy link
Copy Markdown

Since this fork is more maintained than the upstream, I will repost here python-openxml#1575


Fixes python-openxml#540

Modern image formats (PNG, JPEG, Tiff, etc) embed the orientation in Exif metadata.
When rotating or displaying an image, software like Word, PowerPoint, macOS Preview, Android Google Photo, etc do not rotate the actual pixel data but instead change the orientation metadata integer (https://exifstrip.com/guides/orientation-tag-explained)

This is governed in OpenXML with the a:xfrm (transform) tag. This tag can contain the rot, flipV, and flipH attributes. By mapping the 1-8 orientation values to the respective rot and flip attributes it's possible to display images that were rotated by merely changing the Exif metadata with the correct orientation.

This PR add support for image orientation.

By default it gets orientation from Exif metadata. If orientation metadata is not available it falls back to "Normal" orientation (like now, defined by raw pixel data).

It is also possible to force a given orientation programmatically by:

document.add_picture(path_to_picture, width=Inches(3), orientation=EXIF_ORIENTATION.ROTATE_90))
last_paragraph = document.paragraphs[-1]
last_paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER

Summary by CodeRabbit

  • New Features

    • Added automatic EXIF orientation handling when inserting pictures.
    • Images can now be rotated or flipped based on embedded orientation metadata.
    • Added optional orientation control to picture insertion APIs.
    • Added orientation detection for JPEG, PNG, and TIFF images.
    • Corrected displayed image dimensions for rotated images.
  • Tests

    • Added coverage for orientation detection, transformations, defaults, and picture insertion.

… from Exif data then fallback to normal orientation.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds EXIF orientation parsing for JPEG, PNG, and TIFF images. Propagates orientation through picture APIs. Applies DrawingML rotation, flip, and axis-swap transforms. Adds coverage for metadata parsing, API forwarding, and shape generation.

Changes

EXIF orientation support

Layer / File(s) Summary
Orientation contract and DrawingML angle type
src/docx/enum/shape.py, src/docx/image/constants.py, src/docx/oxml/simpletypes.py
Adds EXIF_ORIENTATION, PNG and TIFF orientation constants, and the ST_Angle type.
Image metadata extraction
src/docx/image/image.py, src/docx/image/jpeg.py, src/docx/image/png.py, src/docx/image/tiff.py, tests/image/*
Reads EXIF orientation from JPEG APP1, PNG eXIf, and TIFF data. Exposes orientation through image headers and validates default behavior.
Picture API orientation propagation
src/docx/document.py, src/docx/text/run.py, src/docx/parts/story.py, tests/test_document.py, tests/text/test_run.py, tests/parts/test_story.py
Adds orientation parameters to picture APIs and resolves AUTO from image metadata.
DrawingML orientation transforms
src/docx/oxml/shape.py, tests/oxml/test_shape.py
Applies rotation and flip attributes and swaps inline extent axes for rotated orientations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Document
  participant Run
  participant StoryPart
  participant CT_Inline
  participant CT_Picture
  Document->>Run: add_picture(..., orientation)
  Run->>StoryPart: new_pic_inline(..., orientation)
  StoryPart->>StoryPart: resolve AUTO from Image.orientation
  StoryPart->>CT_Inline: create inline picture
  CT_Inline->>CT_Picture: create picture with orientation
  CT_Picture->>CT_Picture: apply rotation and flip transforms
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding image orientation support.
Linked Issues check ✅ Passed The changes address automatic image rotation from EXIF metadata and preserve normal orientation when metadata is absent [#540].
Out of Scope Changes check ✅ Passed All code and test changes support EXIF orientation handling and image rotation requirements.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/image/test_jpeg.py (1)

92-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use non-default values in orientation propagation tests.

The new propagation paths default to 1. The Exif.from_stream() test does not assert exif.orientation, and the _App1Marker.from_stream() test passes 1 through both fixture and expectation. Both tests pass if the new field is dropped and the default is used. Use non-default values and assert that they reach the header and constructor.

Proposed test strengthening
-        jfif_markers_.app1.orientation = 1
+        jfif_markers_.app1.orientation = 6

         assert exif.vert_dpi == vert_dpi
+        assert exif.orientation == 6

-            ANY, marker_code, offset, length, horz_dpi, vert_dpi, 1
+            ANY, marker_code, offset, length, horz_dpi, vert_dpi, 8

-        return instance_mock(request, Tiff, horz_dpi=42, vert_dpi=24, orientation=1)
+        return instance_mock(request, Tiff, horz_dpi=42, vert_dpi=24, orientation=8)

Also applies to: 328-328, 396-396

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/image/test_jpeg.py` at line 92, Strengthen the orientation propagation
tests by replacing default orientation value 1 with non-default values in the
Exif.from_stream(), _App1Marker.from_stream(), and related fixture cases, then
assert those values are propagated into the header and constructor results.
Ensure the fixture and expected value differ from the default so the tests fail
if orientation propagation is removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/docx/image/jpeg.py`:
- Around line 43-45: Update both image constructors to select the EXIF-specific
APP1 marker instead of using the generic markers.app1 result, ensuring a
preceding non-EXIF APP1 marker cannot override EXIF orientation. Adjust the
marker lookup around _App1Marker.from_stream and add a regression test covering
non-EXIF APP1 before EXIF APP1, preserving the later marker’s orientation.

In `@src/docx/oxml/shape.py`:
- Around line 112-114: Update StoryPart.new_pic_inline() to account for
orientation.swaps_axes before invoking image.scaled_dimensions(): swap the
requested width and height first, then create the picture and apply the existing
inline extent handling without causing a second effective swap. Add regression
coverage for width-only, height-only, and both-dimensions requests with
axis-swapping orientations.

In `@src/docx/parts/story.py`:
- Around line 75-78: Update the picture sizing flow around
CT_Inline.new_pic_inline and the InlineShape width/height setters: resolve AUTO
orientation before scaling, swap the requested width and height when
orientation.swaps_axes is true before calling image.scaled_dimensions(), and
apply the same crossed-axis mapping in both setters so explicit dimensions
remain correct after wp:extent axis swapping.

---

Nitpick comments:
In `@tests/image/test_jpeg.py`:
- Line 92: Strengthen the orientation propagation tests by replacing default
orientation value 1 with non-default values in the Exif.from_stream(),
_App1Marker.from_stream(), and related fixture cases, then assert those values
are propagated into the header and constructor results. Ensure the fixture and
expected value differ from the default so the tests fail if orientation
propagation is removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8ac872e-29ea-4822-9578-8dadf6df5ef8

📥 Commits

Reviewing files that changed from the base of the PR and between 5471786 and d1d0055.

📒 Files selected for processing (19)
  • src/docx/document.py
  • src/docx/enum/shape.py
  • src/docx/image/constants.py
  • src/docx/image/image.py
  • src/docx/image/jpeg.py
  • src/docx/image/png.py
  • src/docx/image/tiff.py
  • src/docx/oxml/shape.py
  • src/docx/oxml/simpletypes.py
  • src/docx/parts/story.py
  • src/docx/text/run.py
  • tests/image/test_image.py
  • tests/image/test_jpeg.py
  • tests/image/test_png.py
  • tests/image/test_tiff.py
  • tests/oxml/test_shape.py
  • tests/parts/test_story.py
  • tests/test_document.py
  • tests/text/test_run.py

Comment thread src/docx/image/jpeg.py
Comment on lines +43 to +45
orientation = markers.app1.orientation

return cls(px_width, px_height, horz_dpi, vert_dpi)
return cls(px_width, px_height, horz_dpi, vert_dpi, orientation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Select an EXIF APP1 marker.

When a non-EXIF APP1 marker precedes an EXIF APP1 marker, markers.app1 returns the former. _App1Marker.from_stream() assigns that marker orientation 1, so the later EXIF orientation is ignored. Add an EXIF-specific marker lookup and use it in both constructors. Add a marker-order regression test.

Also applies to: 61-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/docx/image/jpeg.py` around lines 43 - 45, Update both image constructors
to select the EXIF-specific APP1 marker instead of using the generic
markers.app1 result, ensuring a preceding non-EXIF APP1 marker cannot override
EXIF orientation. Adjust the marker lookup around _App1Marker.from_stream and
add a regression test covering non-EXIF APP1 before EXIF APP1, preserving the
later marker’s orientation.

Comment thread src/docx/oxml/shape.py
Comment on lines +112 to +114
pic = CT_Picture.new(pic_id, filename, rId, cx, cy, orientation)
if orientation.swaps_axes:
cx, cy = cy, cx

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve requested display dimensions for rotated images.

image.scaled_dimensions() runs before Line 113 swaps the inline extent axes. For ROTATE_90 or another axis-swapping orientation, a width-only request becomes the displayed height. A caller that passes width=W can receive an image with displayed width based on the unrotated height instead.

Resolve orientation before scaling. In StoryPart.new_pic_inline(), swap the requested width and height before calling image.scaled_dimensions() when orientation.swaps_axes is true. Add width-only, height-only, and both-dimensions regression tests.

Proposed fix
-        rId, image = self.get_or_add_image(image_descriptor)
-        cx, cy = image.scaled_dimensions(width, height)
+        rId, image = self.get_or_add_image(image_descriptor)
         if orientation is EXIF_ORIENTATION.AUTO:
             orientation = image.orientation
+        if orientation.swaps_axes:
+            width, height = height, width
+        cx, cy = image.scaled_dimensions(width, height)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/docx/oxml/shape.py` around lines 112 - 114, Update
StoryPart.new_pic_inline() to account for orientation.swaps_axes before invoking
image.scaled_dimensions(): swap the requested width and height first, then
create the picture and apply the existing inline extent handling without causing
a second effective swap. Add regression coverage for width-only, height-only,
and both-dimensions requests with axis-swapping orientations.

Comment thread src/docx/parts/story.py
Comment on lines +75 to +78
if orientation is EXIF_ORIENTATION.AUTO:
orientation = image.orientation
shape_id, filename = self.next_id, image.filename
return CT_Inline.new_pic_inline(shape_id, rId, filename, cx, cy)
return CT_Inline.new_pic_inline(shape_id, rId, filename, cx, cy, orientation)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scale axis-swapping orientations in display coordinates.

When orientation.swaps_axes is true, image.scaled_dimensions(width, height) consumes display dimensions before CT_Inline.new_pic_inline() swaps wp:extent. The returned InlineShape then has the opposite explicit width and height. Resolve AUTO before scaling. Swap the requested dimensions before calling scaled_dimensions(). Preserve the same crossed-axis rule in the InlineShape.width and InlineShape.height setters.

Proposed fix
         rId, image = self.get_or_add_image(image_descriptor)
-        cx, cy = image.scaled_dimensions(width, height)
         if orientation is EXIF_ORIENTATION.AUTO:
             orientation = image.orientation
+        if orientation.swaps_axes:
+            width, height = height, width
+        cx, cy = image.scaled_dimensions(width, height)
         shape_id, filename = self.next_id, image.filename
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/docx/parts/story.py` around lines 75 - 78, Update the picture sizing flow
around CT_Inline.new_pic_inline and the InlineShape width/height setters:
resolve AUTO orientation before scaling, swap the requested width and height
when orientation.swaps_axes is true before calling image.scaled_dimensions(),
and apply the same crossed-axis mapping in both setters so explicit dimensions
remain correct after wp:extent axis swapping.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Problem with rotation when adding images

1 participant