Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/docx/document.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.

Expand All@@ -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.
Expand Down
65 changes: 65 additions & 0 deletions src/docx/enum/shape.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
"""Enumerations related to DrawingML shapes in WordprocessingML files."""

from __future__ import annotations

import enum


Expand All@@ -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)
2 changes: 2 additions & 0 deletions src/docx/image/constants.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,7 @@ class PNG_CHUNK_TYPE:

IHDR = "IHDR"
pHYs = "pHYs"
eXIf = "eXIf"
IEND = "IEND"


Expand DownExpand Up@@ -141,6 +142,7 @@ class TIFF_TAG:

IMAGE_WIDTH = 0x0100
IMAGE_LENGTH = 0x0101
ORIENTATION = 0x0112
X_RESOLUTION = 0x011A
Y_RESOLUTION = 0x011B
RESOLUTION_UNIT = 0x0128
Expand Down
21 changes: 20 additions & 1 deletion src/docx/image/image.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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:
Expand DownExpand Up@@ -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
29 changes: 24 additions & 5 deletions src/docx/image/jpeg.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):
Expand All@@ -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:
Expand DownExpand Up@@ -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
# -------------------- --- --- ----- ----------------------------
Expand All@@ -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):
Expand All@@ -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
Expand Down
53 changes: 52 additions & 1 deletion src/docx/image/png.py
Original file line numberDiff line numberDiff line change
@@ -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):
Expand All@@ -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:
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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:
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
8 changes: 7 additions & 1 deletion src/docx/image/tiff.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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
Expand Down
Loading