235 lines
7.8 KiB
Python
235 lines
7.8 KiB
Python
|
|
"""Provides objects that can characterize image streams.
|
||
|
|
|
||
|
|
That characterization is as to content type and size, as a required step in including
|
||
|
|
them in a document.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import io
|
||
|
|
import os
|
||
|
|
from typing import IO, Tuple
|
||
|
|
|
||
|
|
from docx.image.exceptions import UnrecognizedImageError
|
||
|
|
from docx.shared import Emu, Inches, Length, lazyproperty
|
||
|
|
|
||
|
|
|
||
|
|
class Image:
|
||
|
|
"""Graphical image stream such as JPEG, PNG, or GIF with properties and methods
|
||
|
|
required by ImagePart."""
|
||
|
|
|
||
|
|
def __init__(self, blob: bytes, filename: str, image_header: BaseImageHeader):
|
||
|
|
super(Image, self).__init__()
|
||
|
|
self._blob = blob
|
||
|
|
self._filename = filename
|
||
|
|
self._image_header = image_header
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_blob(cls, blob: bytes) -> Image:
|
||
|
|
"""Return a new |Image| subclass instance parsed from the image binary contained
|
||
|
|
in `blob`."""
|
||
|
|
stream = io.BytesIO(blob)
|
||
|
|
return cls._from_stream(stream, blob)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_file(cls, image_descriptor: str | IO[bytes]):
|
||
|
|
"""Return a new |Image| subclass instance loaded from the image file identified
|
||
|
|
by `image_descriptor`, a path or file-like object."""
|
||
|
|
if isinstance(image_descriptor, str):
|
||
|
|
path = image_descriptor
|
||
|
|
with open(path, "rb") as f:
|
||
|
|
blob = f.read()
|
||
|
|
stream = io.BytesIO(blob)
|
||
|
|
filename = os.path.basename(path)
|
||
|
|
else:
|
||
|
|
stream = image_descriptor
|
||
|
|
stream.seek(0)
|
||
|
|
blob = stream.read()
|
||
|
|
filename = None
|
||
|
|
return cls._from_stream(stream, blob, filename)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def blob(self):
|
||
|
|
"""The bytes of the image 'file'."""
|
||
|
|
return self._blob
|
||
|
|
|
||
|
|
@property
|
||
|
|
def content_type(self) -> str:
|
||
|
|
"""MIME content type for this image, e.g. ``'image/jpeg'`` for a JPEG image."""
|
||
|
|
return self._image_header.content_type
|
||
|
|
|
||
|
|
@lazyproperty
|
||
|
|
def ext(self):
|
||
|
|
"""The file extension for the image.
|
||
|
|
|
||
|
|
If an actual one is available from a load filename it is used. Otherwise a
|
||
|
|
canonical extension is assigned based on the content type. Does not contain the
|
||
|
|
leading period, e.g. 'jpg', not '.jpg'.
|
||
|
|
"""
|
||
|
|
return os.path.splitext(self._filename)[1][1:]
|
||
|
|
|
||
|
|
@property
|
||
|
|
def filename(self):
|
||
|
|
"""Original image file name, if loaded from disk, or a generic filename if
|
||
|
|
loaded from an anonymous stream."""
|
||
|
|
return self._filename
|
||
|
|
|
||
|
|
@property
|
||
|
|
def px_width(self) -> int:
|
||
|
|
"""The horizontal pixel dimension of the image."""
|
||
|
|
return self._image_header.px_width
|
||
|
|
|
||
|
|
@property
|
||
|
|
def px_height(self) -> int:
|
||
|
|
"""The vertical pixel dimension of the image."""
|
||
|
|
return self._image_header.px_height
|
||
|
|
|
||
|
|
@property
|
||
|
|
def horz_dpi(self) -> int:
|
||
|
|
"""Integer dots per inch for the width of this image.
|
||
|
|
|
||
|
|
Defaults to 72 when not present in the file, as is often the case.
|
||
|
|
"""
|
||
|
|
return self._image_header.horz_dpi
|
||
|
|
|
||
|
|
@property
|
||
|
|
def vert_dpi(self) -> int:
|
||
|
|
"""Integer dots per inch for the height of this image.
|
||
|
|
|
||
|
|
Defaults to 72 when not present in the file, as is often the case.
|
||
|
|
"""
|
||
|
|
return self._image_header.vert_dpi
|
||
|
|
|
||
|
|
@property
|
||
|
|
def width(self) -> Inches:
|
||
|
|
"""A |Length| value representing the native width of the image, calculated from
|
||
|
|
the values of `px_width` and `horz_dpi`."""
|
||
|
|
return Inches(self.px_width / self.horz_dpi)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def height(self) -> Inches:
|
||
|
|
"""A |Length| value representing the native height of the image, calculated from
|
||
|
|
the values of `px_height` and `vert_dpi`."""
|
||
|
|
return Inches(self.px_height / self.vert_dpi)
|
||
|
|
|
||
|
|
def scaled_dimensions(
|
||
|
|
self, width: int | Length | None = None, height: int | Length | None = None
|
||
|
|
) -> Tuple[Length, Length]:
|
||
|
|
"""(cx, cy) pair representing scaled dimensions of this image.
|
||
|
|
|
||
|
|
The native dimensions of the image are scaled by applying the following rules to
|
||
|
|
the `width` and `height` arguments.
|
||
|
|
|
||
|
|
* If both `width` and `height` are specified, the return value is (`width`,
|
||
|
|
`height`); no scaling is performed.
|
||
|
|
* If only one is specified, it is used to compute a scaling factor that is then
|
||
|
|
applied to the unspecified dimension, preserving the aspect ratio of the image.
|
||
|
|
* If both `width` and `height` are |None|, the native dimensions are returned.
|
||
|
|
|
||
|
|
The native dimensions are calculated using the dots-per-inch (dpi) value
|
||
|
|
embedded in the image, defaulting to 72 dpi if no value is specified, as is
|
||
|
|
often the case. The returned values are both |Length| objects.
|
||
|
|
"""
|
||
|
|
if width is None and height is None:
|
||
|
|
return self.width, self.height
|
||
|
|
|
||
|
|
if width is None:
|
||
|
|
assert height is not None
|
||
|
|
scaling_factor = float(height) / float(self.height)
|
||
|
|
width = round(self.width * scaling_factor)
|
||
|
|
|
||
|
|
if height is None:
|
||
|
|
scaling_factor = float(width) / float(self.width)
|
||
|
|
height = round(self.height * scaling_factor)
|
||
|
|
|
||
|
|
return Emu(width), Emu(height)
|
||
|
|
|
||
|
|
@lazyproperty
|
||
|
|
def sha1(self):
|
||
|
|
"""SHA1 hash digest of the image blob."""
|
||
|
|
return hashlib.sha1(self._blob).hexdigest()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def _from_stream(
|
||
|
|
cls,
|
||
|
|
stream: IO[bytes],
|
||
|
|
blob: bytes,
|
||
|
|
filename: str | None = None,
|
||
|
|
) -> Image:
|
||
|
|
"""Return an instance of the |Image| subclass corresponding to the format of the
|
||
|
|
image in `stream`."""
|
||
|
|
image_header = _ImageHeaderFactory(stream)
|
||
|
|
if filename is None:
|
||
|
|
filename = "image.%s" % image_header.default_ext
|
||
|
|
return cls(blob, filename, image_header)
|
||
|
|
|
||
|
|
|
||
|
|
def _ImageHeaderFactory(stream: IO[bytes]):
|
||
|
|
"""A |BaseImageHeader| subclass instance that can parse headers of image in `stream`."""
|
||
|
|
from docx.image import SIGNATURES
|
||
|
|
|
||
|
|
def read_32(stream: IO[bytes]):
|
||
|
|
stream.seek(0)
|
||
|
|
return stream.read(32)
|
||
|
|
|
||
|
|
header = read_32(stream)
|
||
|
|
for cls, offset, signature_bytes in SIGNATURES:
|
||
|
|
end = offset + len(signature_bytes)
|
||
|
|
found_bytes = header[offset:end]
|
||
|
|
if found_bytes == signature_bytes:
|
||
|
|
return cls.from_stream(stream)
|
||
|
|
raise UnrecognizedImageError
|
||
|
|
|
||
|
|
|
||
|
|
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):
|
||
|
|
self._px_width = px_width
|
||
|
|
self._px_height = px_height
|
||
|
|
self._horz_dpi = horz_dpi
|
||
|
|
self._vert_dpi = vert_dpi
|
||
|
|
|
||
|
|
@property
|
||
|
|
def content_type(self) -> str:
|
||
|
|
"""Abstract property definition, must be implemented by all subclasses."""
|
||
|
|
msg = "content_type property must be implemented by all subclasses of BaseImageHeader"
|
||
|
|
raise NotImplementedError(msg)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def default_ext(self) -> str:
|
||
|
|
"""Default filename extension for images of this type.
|
||
|
|
|
||
|
|
An abstract property definition, must be implemented by all subclasses.
|
||
|
|
"""
|
||
|
|
raise NotImplementedError(
|
||
|
|
"default_ext property must be implemented by all subclasses of BaseImageHeader"
|
||
|
|
)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def px_width(self):
|
||
|
|
"""The horizontal pixel dimension of the image."""
|
||
|
|
return self._px_width
|
||
|
|
|
||
|
|
@property
|
||
|
|
def px_height(self):
|
||
|
|
"""The vertical pixel dimension of the image."""
|
||
|
|
return self._px_height
|
||
|
|
|
||
|
|
@property
|
||
|
|
def horz_dpi(self):
|
||
|
|
"""Integer dots per inch for the width of this image.
|
||
|
|
|
||
|
|
Defaults to 72 when not present in the file, as is often the case.
|
||
|
|
"""
|
||
|
|
return self._horz_dpi
|
||
|
|
|
||
|
|
@property
|
||
|
|
def vert_dpi(self):
|
||
|
|
"""Integer dots per inch for the height of this image.
|
||
|
|
|
||
|
|
Defaults to 72 when not present in the file, as is often the case.
|
||
|
|
"""
|
||
|
|
return self._vert_dpi
|