From d1d0055467edaa729336b51168eed0bfda08acdd Mon Sep 17 00:00:00 2001 From: thez3ro Date: Tue, 4 Aug 2026 10:18:11 +0200 Subject: [PATCH] add support for image orientation. tries to get the image orientation from Exif data then fallback to normal orientation. --- src/docx/document.py | 7 ++- src/docx/enum/shape.py | 65 +++++++++++++++++++++++++++ src/docx/image/constants.py | 2 + src/docx/image/image.py | 21 ++++++++- src/docx/image/jpeg.py | 29 +++++++++--- src/docx/image/png.py | 53 +++++++++++++++++++++- src/docx/image/tiff.py | 8 +++- src/docx/oxml/shape.py | 40 +++++++++++++++-- src/docx/oxml/simpletypes.py | 4 ++ src/docx/parts/story.py | 6 ++- src/docx/text/run.py | 6 ++- tests/image/test_image.py | 14 ++++++ tests/image/test_jpeg.py | 26 ++++++++++- tests/image/test_png.py | 85 ++++++++++++++++++++++++++++++++++- tests/image/test_tiff.py | 10 ++++- tests/oxml/test_shape.py | 86 ++++++++++++++++++++++++++++++++++++ tests/parts/test_story.py | 2 + tests/test_document.py | 5 ++- tests/text/test_run.py | 5 ++- 19 files changed, 453 insertions(+), 21 deletions(-) create mode 100644 tests/oxml/test_shape.py diff --git a/src/docx/document.py b/src/docx/document.py index 73757b46d..681a2207c 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -9,6 +9,7 @@ from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_SECTION +from docx.enum.shape import EXIF_ORIENTATION from docx.enum.text import WD_BREAK from docx.section import Section, Sections from docx.shared import ElementProxy, Emu, Inches, Length @@ -123,6 +124,7 @@ def add_picture( image_path_or_stream: str | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, + orientation: EXIF_ORIENTATION = EXIF_ORIENTATION.AUTO, ): """Return new picture shape added in its own paragraph at end of the document. @@ -133,9 +135,12 @@ def add_picture( aspect ratio of the image. The native size of the picture is calculated using the dots-per-inch (dpi) value specified in the image file, defaulting to 72 dpi if no value is specified, as is often the case. + + `orientation` defaults to |AUTO|, which applies the Exif/TIFF Orientation + embedded in the image. """ run = self.add_paragraph().add_run() - return run.add_picture(image_path_or_stream, width, height) + return run.add_picture(image_path_or_stream, width, height, orientation) def add_section(self, start_type: WD_SECTION = WD_SECTION.NEW_PAGE): """Return a |Section| object newly added at the end of the document. diff --git a/src/docx/enum/shape.py b/src/docx/enum/shape.py index ed086c38d..22565334c 100644 --- a/src/docx/enum/shape.py +++ b/src/docx/enum/shape.py @@ -1,5 +1,7 @@ """Enumerations related to DrawingML shapes in WordprocessingML files.""" +from __future__ import annotations + import enum @@ -17,3 +19,66 @@ class WD_INLINE_SHAPE_TYPE(enum.Enum): WD_INLINE_SHAPE = WD_INLINE_SHAPE_TYPE + + +class EXIF_ORIENTATION(enum.Enum): + """Exif/TIFF Orientation mapped to DrawingML ``a:xfrm`` attributes. + https://exifstrip.com/guides/orientation-tag-explained + + Each member exposes: + * ``.rot`` — DrawingML ``ST_Angle`` (1/60000°, clockwise) + * ``.flip_h`` — ``a:xfrm/@flipH`` + * ``.flip_v`` — ``a:xfrm/@flipV`` + + ``AUTO`` resolves to the orientation embedded in the image (or ``NORMAL`` + when absent). The enum value for concrete members is the Orientation tag + integer (1-8). + """ + + rot: int + flip_h: bool + flip_v: bool + + def __new__(cls, exif_value: int, rot: int, flip_h: bool, flip_v: bool): + self = object.__new__(cls) + self._value_ = exif_value + self.rot = rot + self.flip_h = flip_h + self.flip_v = flip_v + return self + + def __str__(self) -> str: + return f"{self.name} ({self.value})" + + @property + def swaps_axes(self) -> bool: + """True when this orientation rotates the image by 90° or 270°.""" + return self in ( + EXIF_ORIENTATION.TRANSPOSE, + EXIF_ORIENTATION.ROTATE_90, + EXIF_ORIENTATION.TRANSVERSE, + EXIF_ORIENTATION.ROTATE_270, + ) + + @classmethod + def from_exif_value(cls, value: int | None) -> EXIF_ORIENTATION: + """Return the member for Exif Orientation `value`, or |NORMAL| if unknown.""" + if value is None: + return cls.NORMAL + try: + member = cls(value) + except ValueError: + return cls.NORMAL + if member is cls.AUTO: + return cls.NORMAL + return member + + AUTO = (0, 0, False, False) + NORMAL = (1, 0, False, False) + FLIP_HORIZONTAL = (2, 0, True, False) + ROTATE_180 = (3, 10_800_000, False, False) + FLIP_VERTICAL = (4, 0, False, True) + TRANSPOSE = (5, 16_200_000, True, False) + ROTATE_90 = (6, 5_400_000, False, False) + TRANSVERSE = (7, 5_400_000, True, False) + ROTATE_270 = (8, 16_200_000, False, False) diff --git a/src/docx/image/constants.py b/src/docx/image/constants.py index 03fae5855..24ab27868 100644 --- a/src/docx/image/constants.py +++ b/src/docx/image/constants.py @@ -112,6 +112,7 @@ class PNG_CHUNK_TYPE: IHDR = "IHDR" pHYs = "pHYs" + eXIf = "eXIf" IEND = "IEND" @@ -141,6 +142,7 @@ class TIFF_TAG: IMAGE_WIDTH = 0x0100 IMAGE_LENGTH = 0x0101 + ORIENTATION = 0x0112 X_RESOLUTION = 0x011A Y_RESOLUTION = 0x011B RESOLUTION_UNIT = 0x0128 diff --git a/src/docx/image/image.py b/src/docx/image/image.py index e5e7f8a13..a18da8595 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -11,6 +11,7 @@ import os from typing import IO, Tuple +from docx.enum.shape import EXIF_ORIENTATION from docx.image.exceptions import UnrecognizedImageError from docx.shared import Emu, Inches, Length, lazyproperty @@ -101,6 +102,11 @@ def vert_dpi(self) -> int: """ return self._image_header.vert_dpi + @property + def orientation(self) -> EXIF_ORIENTATION: + """Exif/TIFF Orientation for this image, defaulting to |NORMAL| when absent.""" + return EXIF_ORIENTATION.from_exif_value(self._image_header.orientation) + @property def width(self) -> Inches: """A |Length| value representing the native width of the image, calculated from @@ -185,11 +191,19 @@ def read_32(stream: IO[bytes]): class BaseImageHeader: """Base class for image header subclasses like |Jpeg| and |Tiff|.""" - def __init__(self, px_width: int, px_height: int, horz_dpi: int, vert_dpi: int): + def __init__( + self, + px_width: int, + px_height: int, + horz_dpi: int, + vert_dpi: int, + orientation: int = 1, + ): self._px_width = px_width self._px_height = px_height self._horz_dpi = horz_dpi self._vert_dpi = vert_dpi + self._orientation = orientation @property def content_type(self) -> str: @@ -232,3 +246,8 @@ def vert_dpi(self): Defaults to 72 when not present in the file, as is often the case. """ return self._vert_dpi + + @property + def orientation(self) -> int: + """Exif/TIFF Orientation tag value (1-8), defaulting to 1 (normal).""" + return self._orientation diff --git a/src/docx/image/jpeg.py b/src/docx/image/jpeg.py index 74da51871..ec74906df 100644 --- a/src/docx/image/jpeg.py +++ b/src/docx/image/jpeg.py @@ -40,8 +40,9 @@ def from_stream(cls, stream): px_height = markers.sof.px_height horz_dpi = markers.app1.horz_dpi vert_dpi = markers.app1.vert_dpi + 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) class Jfif(Jpeg): @@ -57,8 +58,13 @@ def from_stream(cls, stream): px_height = markers.sof.px_height horz_dpi = markers.app0.horz_dpi vert_dpi = markers.app0.vert_dpi + # Many JFIF files also carry an Exif APP1 segment with Orientation. + try: + orientation = markers.app1.orientation + except KeyError: + orientation = 1 - return cls(px_width, px_height, horz_dpi, vert_dpi) + return cls(px_width, px_height, horz_dpi, vert_dpi, orientation) class _JfifMarkers: @@ -336,14 +342,15 @@ def from_stream(cls, stream, marker_code, offset): class _App1Marker(_Marker): """Represents a JFIF APP1 (Exif) marker segment.""" - def __init__(self, marker_code, offset, length, horz_dpi, vert_dpi): + def __init__(self, marker_code, offset, length, horz_dpi, vert_dpi, orientation=1): super(_App1Marker, self).__init__(marker_code, offset, length) self._horz_dpi = horz_dpi self._vert_dpi = vert_dpi + self._orientation = orientation @classmethod def from_stream(cls, stream, marker_code, offset): - """Extract the horizontal and vertical dots-per-inch value from the APP1 header + """Extract the horizontal and vertical dots-per-inch values and orientation from the APP1 header at `offset` in `stream`.""" # field off len type notes # -------------------- --- --- ----- ---------------------------- @@ -357,7 +364,14 @@ def from_stream(cls, stream, marker_code, offset): if cls._is_non_Exif_APP1_segment(stream, offset): return cls(marker_code, offset, segment_length, 72, 72) tiff = cls._tiff_from_exif_segment(stream, offset, segment_length) - return cls(marker_code, offset, segment_length, tiff.horz_dpi, tiff.vert_dpi) + return cls( + marker_code, + offset, + segment_length, + tiff.horz_dpi, + tiff.vert_dpi, + tiff.orientation, + ) @property def horz_dpi(self): @@ -371,6 +385,11 @@ def vert_dpi(self): specified.""" return self._vert_dpi + @property + def orientation(self): + """Exif Orientation tag value (1-8), defaulting to 1 when absent.""" + return self._orientation + @classmethod def _is_non_Exif_APP1_segment(cls, stream, offset): """Return True if the APP1 segment at `offset` in `stream` is NOT an Exif diff --git a/src/docx/image/png.py b/src/docx/image/png.py index dd3cf819e..76a4cd079 100644 --- a/src/docx/image/png.py +++ b/src/docx/image/png.py @@ -1,7 +1,14 @@ +"""Objects related to parsing headers of PNG image streams.""" + +from __future__ import annotations + +import io + from .constants import MIME_TYPE, PNG_CHUNK_TYPE from .exceptions import InvalidImageStreamError from .helpers import BIG_ENDIAN, StreamReader from .image import BaseImageHeader +from .tiff import Tiff class Png(BaseImageHeader): @@ -28,8 +35,9 @@ def from_stream(cls, stream): px_height = parser.px_height horz_dpi = parser.horz_dpi vert_dpi = parser.vert_dpi + orientation = parser.orientation - return cls(px_width, px_height, horz_dpi, vert_dpi) + return cls(px_width, px_height, horz_dpi, vert_dpi, orientation) class _PngParser: @@ -80,6 +88,14 @@ def vert_dpi(self): return 72 return self._dpi(pHYs.units_specifier, pHYs.vert_px_per_unit) + @property + def orientation(self): + """Exif/TIFF Orientation tag value (1-8), defaulting to 1 when absent.""" + eXIf = self._chunks.eXIf + if eXIf is None: + return 1 + return eXIf.orientation + @staticmethod def _dpi(units_specifier, px_per_unit): """Return dots per inch value calculated from `units_specifier` and @@ -118,6 +134,12 @@ def pHYs(self): match = lambda chunk: chunk.type_name == PNG_CHUNK_TYPE.pHYs # noqa return self._find_first(match) + @property + def eXIf(self): + """eXIf chunk in PNG image, or |None| if not present.""" + match = lambda chunk: chunk.type_name == PNG_CHUNK_TYPE.eXIf # noqa + return self._find_first(match) + def _find_first(self, match): """Return first chunk in stream order returning True for function `match`.""" for chunk in self._chunks: @@ -171,6 +193,7 @@ def _ChunkFactory(chunk_type, stream_rdr, offset): chunk_cls_map = { PNG_CHUNK_TYPE.IHDR: _IHDRChunk, PNG_CHUNK_TYPE.pHYs: _pHYsChunk, + PNG_CHUNK_TYPE.eXIf: _eXIfChunk, } chunk_cls = chunk_cls_map.get(chunk_type, _Chunk) return chunk_cls.from_offset(chunk_type, stream_rdr, offset) @@ -251,3 +274,31 @@ def vert_px_per_unit(self): @property def units_specifier(self): return self._units_specifier + + +class _eXIfChunk(_Chunk): + """eXIf chunk, contains an Exif profile with optional Orientation tag.""" + + def __init__(self, chunk_type, orientation): + super(_eXIfChunk, self).__init__(chunk_type) + self._orientation = orientation + + @classmethod + def from_offset(cls, chunk_type, stream_rdr, offset): + """Return an |_eXIfChunk| with Orientation parsed from the eXIf payload. + + PNG eXIf chunk data is a TIFF/Exif IFD (usually starting with ``II``/``MM``). + Some encoders prepend ``Exif\\x00\\x00``; that prefix is stripped when present. + """ + chunk_data_len = stream_rdr.read_long(offset, -8) + stream_rdr.seek(offset) + data = stream_rdr.read(chunk_data_len) + if data.startswith(b"Exif\x00\x00"): + data = data[6:] + tiff = Tiff.from_stream(io.BytesIO(data)) + return cls(chunk_type, tiff.orientation) + + @property + def orientation(self): + """Exif Orientation tag value (1-8).""" + return self._orientation diff --git a/src/docx/image/tiff.py b/src/docx/image/tiff.py index 1194929af..db85e6e4b 100644 --- a/src/docx/image/tiff.py +++ b/src/docx/image/tiff.py @@ -30,8 +30,9 @@ def from_stream(cls, stream): px_height = parser.px_height horz_dpi = parser.horz_dpi vert_dpi = parser.vert_dpi + orientation = parser.orientation - return cls(px_width, px_height, horz_dpi, vert_dpi) + return cls(px_width, px_height, horz_dpi, vert_dpi, orientation) class _TiffParser: @@ -77,6 +78,11 @@ def px_width(self): image.""" return self._ifd_entries.get(TIFF_TAG.IMAGE_WIDTH) + @property + def orientation(self): + """Exif/TIFF Orientation tag value (1-8), defaulting to 1 when absent.""" + return self._ifd_entries.get(TIFF_TAG.ORIENTATION, 1) + @classmethod def _detect_endian(cls, stream): """Return either BIG_ENDIAN or LITTLE_ENDIAN depending on the endian indicator diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index c6df8e7b8..b99c6cd55 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -4,13 +4,16 @@ from typing import TYPE_CHECKING, cast +from docx.enum.shape import EXIF_ORIENTATION from docx.oxml.ns import nsdecls from docx.oxml.parser import parse_xml from docx.oxml.simpletypes import ( + ST_Angle, ST_Coordinate, ST_DrawingElementId, ST_PositiveCoordinate, ST_RelationshipId, + XsdBoolean, XsdString, XsdToken, ) @@ -91,14 +94,24 @@ def new(cls, cx: Length, cy: Length, shape_id: int, pic: CT_Picture) -> CT_Inlin @classmethod def new_pic_inline( - cls, shape_id: int, rId: str, filename: str, cx: Length, cy: Length + cls, + shape_id: int, + rId: str, + filename: str, + cx: Length, + cy: Length, + orientation: EXIF_ORIENTATION = EXIF_ORIENTATION.NORMAL, ) -> CT_Inline: """Create `wp:inline` element containing a `pic:pic` element. The contents of the `pic:pic` element is taken from the argument values. + When `orientation` swaps axes (90°/270°), `wp:extent` uses the swapped + display size while the picture transform keeps the native `cx`/`cy`. """ pic_id = 0 # Word doesn't seem to use this, but does not omit it - pic = CT_Picture.new(pic_id, filename, rId, cx, cy) + pic = CT_Picture.new(pic_id, filename, rId, cx, cy, orientation) + if orientation.swaps_axes: + cx, cy = cy, cx inline = cls.new(cx, cy, shape_id, pic) return inline @@ -144,7 +157,15 @@ class CT_Picture(BaseOxmlElement): spPr: CT_ShapeProperties = OneAndOnlyOne("pic:spPr") # pyright: ignore[reportAssignmentType] @classmethod - def new(cls, pic_id: int, filename: str, rId: str, cx: Length, cy: Length) -> CT_Picture: + def new( + cls, + pic_id: int, + filename: str, + rId: str, + cx: Length, + cy: Length, + orientation: EXIF_ORIENTATION = EXIF_ORIENTATION.NORMAL, + ) -> CT_Picture: """A new minimum viable `` (picture) element.""" pic = parse_xml(cls._pic_xml()) pic.nvPicPr.cNvPr.id = pic_id @@ -152,6 +173,10 @@ def new(cls, pic_id: int, filename: str, rId: str, cx: Length, cy: Length) -> CT pic.blipFill.blip.embed = rId pic.spPr.cx = cx pic.spPr.cy = cy + xfrm = pic.spPr.get_or_add_xfrm() + xfrm.rot = orientation.rot + xfrm.flipH = orientation.flip_h + xfrm.flipV = orientation.flip_v return pic @classmethod @@ -273,6 +298,15 @@ class CT_Transform2D(BaseOxmlElement): off = ZeroOrOne("a:off", successors=("a:ext",)) ext = ZeroOrOne("a:ext", successors=()) + rot: int = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "rot", ST_Angle, default=0 + ) + flipH: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "flipH", XsdBoolean, default=False + ) + flipV: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "flipV", XsdBoolean, default=False + ) @property def cx(self): diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index a0fc87d3f..175b305ac 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -178,6 +178,10 @@ def validate(cls, value: Any) -> None: cls.validate_int_in_range(value, 0, 18446744073709551615) +class ST_Angle(BaseIntType): + """Angle in DrawingML units: 1/60000 of a degree.""" + + class ST_BrClear(XsdString): @classmethod def validate(cls, value: str) -> None: diff --git a/src/docx/parts/story.py b/src/docx/parts/story.py index 7482c91a8..51b8c2897 100644 --- a/src/docx/parts/story.py +++ b/src/docx/parts/story.py @@ -4,6 +4,7 @@ from typing import IO, TYPE_CHECKING, Tuple, cast +from docx.enum.shape import EXIF_ORIENTATION from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import XmlPart from docx.oxml.shape import CT_Inline @@ -62,6 +63,7 @@ def new_pic_inline( image_descriptor: str | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, + orientation: EXIF_ORIENTATION = EXIF_ORIENTATION.AUTO, ) -> CT_Inline: """Return a newly-created `w:inline` element. @@ -70,8 +72,10 @@ def new_pic_inline( """ 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 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) @property def next_id(self) -> int: diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 57ea31fa4..c7e970044 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -5,6 +5,7 @@ from typing import IO, TYPE_CHECKING, Iterator, cast from docx.drawing import Drawing +from docx.enum.shape import EXIF_ORIENTATION from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK from docx.oxml.drawing import CT_Drawing @@ -61,6 +62,7 @@ def add_picture( image_path_or_stream: str | IO[bytes], width: int | Length | None = None, height: int | Length | None = None, + orientation: EXIF_ORIENTATION = EXIF_ORIENTATION.AUTO, ) -> InlineShape: """Return |InlineShape| containing image identified by `image_path_or_stream`. @@ -76,7 +78,9 @@ def add_picture( per-inch (dpi) value specified in the image file, defaulting to 72 dpi if no value is specified, as is often the case. """ - inline = self.part.new_pic_inline(image_path_or_stream, width, height) + inline = self.part.new_pic_inline( + image_path_or_stream, width, height, orientation + ) self._r.add_drawing(inline) return InlineShape(inline) diff --git a/tests/image/test_image.py b/tests/image/test_image.py index c13e87305..f29cdcd12 100644 --- a/tests/image/test_image.py +++ b/tests/image/test_image.py @@ -4,6 +4,7 @@ import pytest +from docx.enum.shape import EXIF_ORIENTATION from docx.image.bmp import Bmp from docx.image.exceptions import UnrecognizedImageError from docx.image.gif import Gif @@ -79,6 +80,11 @@ def it_knows_the_horz_and_vert_dpi_of_the_image(self, dpi_fixture): assert image.horz_dpi == horz_dpi assert image.vert_dpi == vert_dpi + def it_knows_the_image_orientation(self, image_header_): + image_header_.orientation = 6 + image = Image(None, None, image_header_) + assert image.orientation is EXIF_ORIENTATION.ROTATE_90 + def it_knows_the_image_native_size(self, size_fixture): image, width, height = size_fixture assert (image.width, image.height) == (width, height) @@ -318,3 +324,11 @@ def it_knows_the_horz_and_vert_dpi_of_the_image(self): image_header = BaseImageHeader(None, None, horz_dpi, vert_dpi) assert image_header.horz_dpi == horz_dpi assert image_header.vert_dpi == vert_dpi + + def it_knows_the_orientation_of_the_image(self): + image_header = BaseImageHeader(None, None, 72, 72, orientation=8) + assert image_header.orientation == 8 + + def it_defaults_orientation_to_normal(self): + image_header = BaseImageHeader(None, None, 72, 72) + assert image_header.orientation == 1 diff --git a/tests/image/test_jpeg.py b/tests/image/test_jpeg.py index 129a07d80..9461f0482 100644 --- a/tests/image/test_jpeg.py +++ b/tests/image/test_jpeg.py @@ -64,6 +64,20 @@ def it_can_construct_from_a_jfif_stream(self, from_jfif_fixture): assert jfif.px_height == cy assert jfif.horz_dpi == horz_dpi assert jfif.vert_dpi == vert_dpi + assert jfif.orientation == 1 + + def it_reads_orientation_from_exif_app1_when_present( + self, stream_, _JfifMarkers_, jfif_markers_ + ): + jfif_markers_.sof.px_width = 111 + jfif_markers_.sof.px_height = 222 + jfif_markers_.app0.horz_dpi = 72 + jfif_markers_.app0.vert_dpi = 72 + jfif_markers_.app1.orientation = 8 + + jfif = Jfif.from_stream(stream_) + + assert jfif.orientation == 8 # fixtures ------------------------------------------------------- @@ -75,6 +89,7 @@ def from_exif_fixture(self, stream_, _JfifMarkers_, jfif_markers_): jfif_markers_.sof.px_height = px_height jfif_markers_.app1.horz_dpi = horz_dpi jfif_markers_.app1.vert_dpi = vert_dpi + jfif_markers_.app1.orientation = 1 return (stream_, _JfifMarkers_, px_width, px_height, horz_dpi, vert_dpi) @pytest.fixture @@ -85,6 +100,9 @@ def from_jfif_fixture(self, stream_, _JfifMarkers_, jfif_markers_): jfif_markers_.sof.px_height = px_height jfif_markers_.app0.horz_dpi = horz_dpi jfif_markers_.app0.vert_dpi = vert_dpi + type(jfif_markers_).app1 = property( + lambda self: (_ for _ in ()).throw(KeyError("no APP1 marker in image")) + ) return (stream_, _JfifMarkers_, px_width, px_height, horz_dpi, vert_dpi) @pytest.fixture @@ -307,7 +325,7 @@ def it_can_construct_from_a_stream_and_offset( _tiff_from_exif_segment_.assert_called_once_with(stream, offset, length) _App1Marker__init_.assert_called_once_with( - ANY, marker_code, offset, length, horz_dpi, vert_dpi + ANY, marker_code, offset, length, horz_dpi, vert_dpi, 1 ) assert isinstance(app1_marker, _App1Marker) @@ -336,6 +354,10 @@ def it_knows_the_image_dpi(self): assert app1.horz_dpi == horz_dpi assert app1.vert_dpi == vert_dpi + def it_knows_the_image_orientation(self): + app1 = _App1Marker(None, None, None, 72, 72, orientation=6) + assert app1.orientation == 6 + # fixtures ------------------------------------------------------- @pytest.fixture @@ -371,7 +393,7 @@ def Tiff_(self, request, tiff_): @pytest.fixture def tiff_(self, request): - return instance_mock(request, Tiff, horz_dpi=42, vert_dpi=24) + return instance_mock(request, Tiff, horz_dpi=42, vert_dpi=24, orientation=1) @pytest.fixture def _tiff_from_exif_segment_(self, request, tiff_): diff --git a/tests/image/test_png.py b/tests/image/test_png.py index 5379b403b..aa41a5792 100644 --- a/tests/image/test_png.py +++ b/tests/image/test_png.py @@ -14,6 +14,7 @@ _ChunkParser, _Chunks, _IHDRChunk, + _eXIfChunk, _pHYsChunk, _PngParser, ) @@ -36,11 +37,12 @@ def it_can_construct_from_a_png_stream(self, stream_, _PngParser_, png_parser_, png_parser_.px_height = px_height png_parser_.horz_dpi = horz_dpi png_parser_.vert_dpi = vert_dpi + png_parser_.orientation = 6 png = Png.from_stream(stream_) _PngParser_.parse.assert_called_once_with(stream_) - Png__init__.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) + Png__init__.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi, 6) assert isinstance(png, Png) def it_knows_its_content_type(self): @@ -97,6 +99,14 @@ def it_defaults_image_dpi_to_72(self, no_dpi_fixture): assert png_parser.horz_dpi == 72 assert png_parser.vert_dpi == 72 + def it_knows_the_image_orientation(self, chunks_): + chunks_.eXIf.orientation = 8 + assert _PngParser(chunks_).orientation == 8 + + def it_defaults_orientation_to_normal_when_no_exif_chunk(self, chunks_): + chunks_.eXIf = None + assert _PngParser(chunks_).orientation == 1 + # fixtures ------------------------------------------------------- @pytest.fixture @@ -172,6 +182,10 @@ def it_provides_access_to_the_pHYs_chunk(self, pHYs_fixture): chunks, expected_chunk = pHYs_fixture assert chunks.pHYs == expected_chunk + def it_provides_access_to_the_eXIf_chunk(self, eXIf_fixture): + chunks, expected_chunk = eXIf_fixture + assert chunks.eXIf == expected_chunk + def it_raises_if_theres_no_IHDR_chunk(self, no_IHDR_fixture): chunks = no_IHDR_fixture with pytest.raises(InvalidImageStreamError): @@ -199,6 +213,20 @@ def IHDR_fixture(self, IHDR_chunk_, pHYs_chunk_): chunks = _Chunks(chunks) return chunks, IHDR_chunk_ + @pytest.fixture + def eXIf_chunk_(self, request): + return instance_mock(request, _eXIfChunk, type_name=PNG_CHUNK_TYPE.eXIf) + + @pytest.fixture(params=[True, False]) + def eXIf_fixture(self, request, IHDR_chunk_, eXIf_chunk_): + has_eXIf_chunk = request.param + chunks = [IHDR_chunk_] + if has_eXIf_chunk: + chunks.append(eXIf_chunk_) + expected_chunk = eXIf_chunk_ if has_eXIf_chunk else None + chunks = _Chunks(chunks) + return chunks, expected_chunk + @pytest.fixture def IHDR_chunk_(self, request): return instance_mock(request, _IHDRChunk, type_name=PNG_CHUNK_TYPE.IHDR) @@ -331,14 +359,18 @@ def it_constructs_the_appropriate_Chunk_subclass(self, call_fixture): params=[ PNG_CHUNK_TYPE.IHDR, PNG_CHUNK_TYPE.pHYs, + PNG_CHUNK_TYPE.eXIf, PNG_CHUNK_TYPE.IEND, ] ) - def call_fixture(self, request, _IHDRChunk_, _pHYsChunk_, _Chunk_, stream_rdr_): + def call_fixture( + self, request, _IHDRChunk_, _pHYsChunk_, _eXIfChunk_, _Chunk_, stream_rdr_ + ): chunk_type = request.param chunk_cls_ = { PNG_CHUNK_TYPE.IHDR: _IHDRChunk_, PNG_CHUNK_TYPE.pHYs: _pHYsChunk_, + PNG_CHUNK_TYPE.eXIf: _eXIfChunk_, PNG_CHUNK_TYPE.IEND: _Chunk_, }[chunk_type] offset = 999 @@ -354,6 +386,16 @@ def _Chunk_(self, request, chunk_): def chunk_(self, request): return instance_mock(request, _Chunk) + @pytest.fixture + def _eXIfChunk_(self, request, exif_chunk_): + _eXIfChunk_ = class_mock(request, "docx.image.png._eXIfChunk") + _eXIfChunk_.from_offset.return_value = exif_chunk_ + return _eXIfChunk_ + + @pytest.fixture + def exif_chunk_(self, request): + return instance_mock(request, _eXIfChunk) + @pytest.fixture def _IHDRChunk_(self, request, ihdr_chunk_): _IHDRChunk_ = class_mock(request, "docx.image.png._IHDRChunk") @@ -424,3 +466,42 @@ def from_offset_fixture(self): stream_rdr = StreamReader(io.BytesIO(bytes_), BIG_ENDIAN) offset, horz_px_per_unit, vert_px_per_unit, units_specifier = (0, 42, 24, 1) return (stream_rdr, offset, horz_px_per_unit, vert_px_per_unit, units_specifier) + + +class Describe_eXIfChunk: + def it_can_construct_from_a_stream_and_offset(self, from_offset_fixture): + stream_rdr, offset, expected_orientation = from_offset_fixture + eXIf_chunk = _eXIfChunk.from_offset(PNG_CHUNK_TYPE.eXIf, stream_rdr, offset) + assert isinstance(eXIf_chunk, _eXIfChunk) + assert eXIf_chunk.orientation == expected_orientation + + def it_strips_an_exif_identifier_prefix_when_present(self): + # length(4) + type(4) are before data; we place length at offset-8. + # TIFF: MM, magic 42, IFD0 at 8; IFD with one SHORT Orientation=6 entry. + tiff = ( + b"MM\x00*\x00\x00\x00\x08" # header, IFD0 offset 8 + b"\x00\x01" # 1 IFD entry + b"\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00" # Orientation SHORT = 6 + b"\x00\x00\x00\x00" # next IFD + ) + payload = b"Exif\x00\x00" + tiff + # prepend fake length field so read_long(offset, -8) works + blob = len(payload).to_bytes(4, "big") + b"eXIf" + payload + stream_rdr = StreamReader(io.BytesIO(blob), BIG_ENDIAN) + offset = 8 + eXIf_chunk = _eXIfChunk.from_offset(PNG_CHUNK_TYPE.eXIf, stream_rdr, offset) + assert eXIf_chunk.orientation == 6 + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def from_offset_fixture(self): + tiff = ( + b"MM\x00*\x00\x00\x00\x08" + b"\x00\x01" + b"\x01\x12\x00\x03\x00\x00\x00\x01\x00\x08\x00\x00" + b"\x00\x00\x00\x00" + ) + blob = len(tiff).to_bytes(4, "big") + b"eXIf" + tiff + stream_rdr = StreamReader(io.BytesIO(blob), BIG_ENDIAN) + return stream_rdr, 8, 8 diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index 35344eede..782888f68 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -39,11 +39,12 @@ def it_can_construct_from_a_tiff_stream(self, stream_, _TiffParser_, tiff_parser tiff_parser_.px_height = px_height tiff_parser_.horz_dpi = horz_dpi tiff_parser_.vert_dpi = vert_dpi + tiff_parser_.orientation = 1 tiff = Tiff.from_stream(stream_) _TiffParser_.parse.assert_called_once_with(stream_) - Tiff__init_.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi) + Tiff__init_.assert_called_once_with(ANY, px_width, px_height, horz_dpi, vert_dpi, 1) assert isinstance(tiff, Tiff) def it_knows_its_content_type(self): @@ -110,6 +111,13 @@ def it_knows_image_width_and_height_after_parsing(self): assert tiff_parser.px_width == px_width assert tiff_parser.px_height == px_height + def it_knows_orientation_after_parsing(self): + ifd_entries = _IfdEntries({TIFF_TAG.ORIENTATION: 6}) + assert _TiffParser(ifd_entries).orientation == 6 + + def it_defaults_orientation_to_normal_when_tag_absent(self): + assert _TiffParser(_IfdEntries({})).orientation == 1 + def it_knows_the_horz_and_vert_dpi_after_parsing(self, dpi_fixture): tiff_parser, expected_horz_dpi, expected_vert_dpi = dpi_fixture assert tiff_parser.horz_dpi == expected_horz_dpi diff --git a/tests/oxml/test_shape.py b/tests/oxml/test_shape.py new file mode 100644 index 000000000..51404e15f --- /dev/null +++ b/tests/oxml/test_shape.py @@ -0,0 +1,86 @@ +# pyright: reportPrivateUsage=false + +"""Test suite for the docx.oxml.shape module.""" + +from __future__ import annotations + +import pytest + +from docx.enum.shape import EXIF_ORIENTATION +from docx.oxml.shape import CT_Inline, CT_Picture +from docx.shared import Emu + + +class DescribeCT_Picture: + """Unit-test suite for `docx.oxml.shape.CT_Picture` objects.""" + + @pytest.mark.parametrize( + ("orientation", "expected_rot", "expected_flip_h", "expected_flip_v"), + [ + (EXIF_ORIENTATION.NORMAL, None, None, None), + (EXIF_ORIENTATION.FLIP_HORIZONTAL, None, "1", None), + (EXIF_ORIENTATION.ROTATE_180, "10800000", None, None), + (EXIF_ORIENTATION.FLIP_VERTICAL, None, None, "1"), + (EXIF_ORIENTATION.TRANSPOSE, "16200000", "1", None), + (EXIF_ORIENTATION.ROTATE_90, "5400000", None, None), + (EXIF_ORIENTATION.TRANSVERSE, "5400000", "1", None), + (EXIF_ORIENTATION.ROTATE_270, "16200000", None, None), + ], + ) + def it_can_set_exif_orientation_on_xfrm( + self, + orientation: EXIF_ORIENTATION, + expected_rot: str | None, + expected_flip_h: str | None, + expected_flip_v: str | None, + ): + pic = CT_Picture.new( + 1, "image.jpg", "rId1", Emu(914400), Emu(914400), orientation=orientation + ) + + xfrm = pic.spPr.xfrm + assert xfrm is not None + assert xfrm.get("rot") == expected_rot + assert xfrm.get("flipH") == expected_flip_h + assert xfrm.get("flipV") == expected_flip_v + assert xfrm.rot == orientation.rot + assert xfrm.flipH is orientation.flip_h + assert xfrm.flipV is orientation.flip_v + + +class DescribeCT_Inline: + """Unit-test suite for `docx.oxml.shape.CT_Inline` picture factory.""" + + def it_swaps_extent_axes_for_rotated_orientations(self): + inline = CT_Inline.new_pic_inline( + 1, "rId1", "image.jpg", Emu(200), Emu(100), EXIF_ORIENTATION.ROTATE_90 + ) + + assert inline.extent.cx == Emu(100) + assert inline.extent.cy == Emu(200) + pic = inline.graphic.graphicData.pic + assert pic is not None + assert pic.spPr.cx == Emu(200) + assert pic.spPr.cy == Emu(100) + assert pic.spPr.xfrm.rot == EXIF_ORIENTATION.ROTATE_90.rot + + +class DescribeEXIF_ORIENTATION: + def it_knows_which_members_swap_axes(self): + assert EXIF_ORIENTATION.NORMAL.swaps_axes is False + assert EXIF_ORIENTATION.ROTATE_180.swaps_axes is False + assert EXIF_ORIENTATION.ROTATE_90.swaps_axes is True + assert EXIF_ORIENTATION.ROTATE_270.swaps_axes is True + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, EXIF_ORIENTATION.NORMAL), + (1, EXIF_ORIENTATION.NORMAL), + (6, EXIF_ORIENTATION.ROTATE_90), + (0, EXIF_ORIENTATION.NORMAL), + (99, EXIF_ORIENTATION.NORMAL), + ], + ) + def it_can_resolve_exif_values(self, value: int | None, expected: EXIF_ORIENTATION): + assert EXIF_ORIENTATION.from_exif_value(value) is expected diff --git a/tests/parts/test_story.py b/tests/parts/test_story.py index 9a1dc7fab..bd7317661 100644 --- a/tests/parts/test_story.py +++ b/tests/parts/test_story.py @@ -2,6 +2,7 @@ import pytest +from docx.enum.shape import EXIF_ORIENTATION from docx.enum.style import WD_STYLE_TYPE from docx.image.image import Image from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -59,6 +60,7 @@ def it_can_create_a_new_pic_inline(self, get_or_add_image_, image_, next_id_prop get_or_add_image_.return_value = "rId42", image_ image_.scaled_dimensions.return_value = 444, 888 image_.filename = "bar.png" + image_.orientation = EXIF_ORIENTATION.NORMAL next_id_prop_.return_value = 24 expected_xml = snippet_text("inline") story_part = StoryPart(None, None, None, None) diff --git a/tests/test_document.py b/tests/test_document.py index 53efacf8d..db6602679 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -12,6 +12,7 @@ from docx.comments import Comment, Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION +from docx.enum.shape import EXIF_ORIENTATION from docx.enum.text import WD_BREAK from docx.opc.coreprops import CoreProperties from docx.oxml.document import CT_Body, CT_Document @@ -120,7 +121,9 @@ def it_can_add_a_picture( picture = document.add_picture(path, width, height) - run_.add_picture.assert_called_once_with(path, width, height) + run_.add_picture.assert_called_once_with( + path, width, height, EXIF_ORIENTATION.AUTO + ) assert picture is picture_ @pytest.mark.parametrize( diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 910f445d1..ef41d260f 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -9,6 +9,7 @@ import pytest from docx import types as t +from docx.enum.shape import EXIF_ORIENTATION from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.oxml.text.paragraph import CT_P @@ -313,7 +314,9 @@ def it_can_add_a_picture( picture = run.add_picture(image, width, height) - document_part_.new_pic_inline.assert_called_once_with(image, width, height) + document_part_.new_pic_inline.assert_called_once_with( + image, width, height, EXIF_ORIENTATION.AUTO + ) assert run._r.xml == xml("w:r/(wp:x,w:drawing/wp:inline{id=42})") InlineShape_.assert_called_once_with(inline) assert picture is picture_