Skip to content

Commit 0be0080

Browse files
author
Steve Canny
committed
img: add Image._analyze_image()
Image._analyze_image() produces essentially all the characteristics of the image, enabling the properties of Image.
1 parent af1afc0 commit 0be0080

File tree

9 files changed

+172
-15
lines changed

9 files changed

+172
-15
lines changed

docx/parts/image.py

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
import hashlib
1010
import os
1111

12+
try:
13+
from PIL import Image as PIL_Image
14+
except ImportError:
15+
import Image as PIL_Image
16+
17+
from docx.opc.constants import CONTENT_TYPE as CT
1218
from docx.opc.package import Part
1319
from docx.shared import lazyproperty
1420

@@ -17,10 +23,17 @@ class Image(object):
1723
"""
1824
A helper object that knows how to analyze an image file.
1925
"""
20-
def __init__(self, blob, filename):
26+
def __init__(
27+
self, blob, filename, content_type, px_width, px_height,
28+
horz_dpi, vert_dpi):
2129
super(Image, self).__init__()
2230
self._blob = blob
2331
self._filename = filename
32+
self._content_type = content_type
33+
self._px_width = px_width
34+
self._px_height = px_height
35+
self._horz_dpi = horz_dpi
36+
self._vert_dpi = vert_dpi
2437

2538
@property
2639
def blob(self):
@@ -34,16 +47,16 @@ def content_type(self):
3447
"""
3548
The MIME type of the image, e.g. 'image/png'.
3649
"""
37-
raise NotImplementedError
50+
return self._content_type
3851

39-
@property
52+
@lazyproperty
4053
def ext(self):
4154
"""
4255
The file extension for the image. If an actual one is available from
4356
a load filename it is used. Otherwise a canonical extension is
4457
assigned based on the content type.
4558
"""
46-
raise NotImplementedError
59+
return os.path.splitext(self._filename)[1]
4760

4861
@property
4962
def filename(self):
@@ -53,23 +66,37 @@ def filename(self):
5366
"""
5467
return self._filename
5568

69+
@property
70+
def horz_dpi(self):
71+
"""
72+
The horizontal dots per inch (dpi) of the image, defaults to 72 when
73+
no dpi information is stored in the image, as is often the case.
74+
"""
75+
return self._horz_dpi
76+
5677
@classmethod
5778
def load(cls, image_descriptor):
5879
"""
5980
Return a new |Image| instance loaded from the image file identified
6081
by *image_descriptor*, a path or file-like object.
6182
"""
6283
if isinstance(image_descriptor, basestring):
63-
path = image_descriptor
64-
with open(path, 'rb') as f:
65-
blob = f.read()
66-
filename = os.path.basename(path)
67-
else:
68-
stream = image_descriptor
69-
stream.seek(0)
70-
blob = stream.read()
71-
filename = None
72-
return cls(blob, filename)
84+
return cls._load_from_path(image_descriptor)
85+
return cls._load_from_stream(image_descriptor)
86+
87+
@property
88+
def px_width(self):
89+
"""
90+
The horizontal pixel dimension of the image
91+
"""
92+
return self._px_width
93+
94+
@property
95+
def px_height(self):
96+
"""
97+
The vertical pixel dimension of the image
98+
"""
99+
return self._px_height
73100

74101
@lazyproperty
75102
def sha1(self):
@@ -78,6 +105,91 @@ def sha1(self):
78105
"""
79106
return hashlib.sha1(self._blob).hexdigest()
80107

108+
@property
109+
def vert_dpi(self):
110+
"""
111+
The vertical dots per inch (dpi) of the image, defaults to 72 when no
112+
dpi information is stored in the image.
113+
"""
114+
return self._vert_dpi
115+
116+
@classmethod
117+
def _analyze_image(cls, stream):
118+
pil_image = cls._open_pillow_image(stream)
119+
content_type = cls._format_content_type(pil_image.format)
120+
px_width, px_height = pil_image.size
121+
try:
122+
horz_dpi, vert_dpi = pil_image.info.get('dpi')
123+
except:
124+
horz_dpi, vert_dpi = (72, 72)
125+
return content_type, px_width, px_height, horz_dpi, vert_dpi
126+
127+
@classmethod
128+
def _def_mime_ext(cls, mime_type):
129+
"""
130+
Return the default file extension, e.g. ``'.png'``, corresponding to
131+
*mime_type*. Raises |KeyError| for unsupported image types.
132+
"""
133+
content_type_extensions = {
134+
CT.BMP: '.bmp', CT.GIF: '.gif', CT.JPEG: '.jpg', CT.PNG: '.png',
135+
CT.TIFF: '.tiff', CT.X_WMF: '.wmf'
136+
}
137+
return content_type_extensions[mime_type]
138+
139+
@classmethod
140+
def _format_content_type(cls, format):
141+
"""
142+
Return the content type string (MIME type for images) corresponding
143+
to the Pillow image format string *format*.
144+
"""
145+
format_content_types = {
146+
'BMP': CT.BMP, 'GIF': CT.GIF, 'JPEG': CT.JPEG, 'PNG': CT.PNG,
147+
'TIFF': CT.TIFF, 'WMF': CT.X_WMF
148+
}
149+
return format_content_types[format]
150+
151+
@classmethod
152+
def _load_from_path(cls, path):
153+
with open(path, 'rb') as f:
154+
blob = f.read()
155+
content_type, px_width, px_height, horz_dpi, vert_dpi = (
156+
cls._analyze_image(f)
157+
)
158+
filename = os.path.basename(path)
159+
return cls(
160+
blob, filename, content_type, px_width, px_height, horz_dpi,
161+
vert_dpi
162+
)
163+
164+
@classmethod
165+
def _load_from_stream(cls, stream):
166+
stream.seek(0)
167+
blob = stream.read()
168+
content_type, px_width, px_height, horz_dpi, vert_dpi = (
169+
cls._analyze_image(stream)
170+
)
171+
filename = 'image%s' % cls._def_mime_ext(content_type)
172+
return cls(
173+
blob, filename, content_type, px_width, px_height, horz_dpi,
174+
vert_dpi
175+
)
176+
177+
@classmethod
178+
def _open_pillow_image(cls, stream):
179+
"""
180+
Return a Pillow ``Image`` instance loaded from the image file-like
181+
object *stream*. The image is validated to confirm it is a supported
182+
image type.
183+
"""
184+
stream.seek(0)
185+
pil_image = PIL_Image.open(stream)
186+
try:
187+
cls._format_content_type(pil_image.format)
188+
except KeyError:
189+
tmpl = "unsupported image format '%s'"
190+
raise ValueError(tmpl % (pil_image.format))
191+
return pil_image
192+
81193

82194
class ImagePart(Part):
83195
"""

tests/parts/test_image.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,52 @@ def it_can_construct_from_an_image_stream(self):
3434
image = Image.load(image_file_stream)
3535
assert isinstance(image, Image)
3636
assert image.sha1 == '79769f1e202add2e963158b532e36c2c0f76a70c'
37-
assert image.filename is None
37+
assert image.filename == 'image.png'
38+
39+
def it_knows_the_extension_of_a_file_based_image(self):
40+
image_file_path = test_file('monty-truth.png')
41+
image = Image.load(image_file_path)
42+
assert image.ext == '.png'
43+
44+
def it_knows_the_extension_of_a_stream_based_image(self):
45+
image_file_path = test_file('monty-truth.png')
46+
with open(image_file_path, 'rb') as image_file_stream:
47+
image = Image.load(image_file_stream)
48+
assert image.ext == '.png'
49+
50+
def it_correctly_characterizes_a_few_known_images(
51+
self, known_image_fixture):
52+
image_path, characteristics = known_image_fixture
53+
ext, content_type, px_width, px_height, horz_dpi, vert_dpi = (
54+
characteristics
55+
)
56+
with open(test_file(image_path), 'rb') as stream:
57+
image = Image.load(stream)
58+
assert image.ext == ext
59+
assert image.content_type == content_type
60+
assert image.px_width == px_width
61+
assert image.px_height == px_height
62+
assert image.horz_dpi == horz_dpi
63+
assert image.vert_dpi == vert_dpi
64+
65+
# fixtures -------------------------------------------------------
66+
67+
@pytest.fixture(params=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
68+
def known_image_fixture(self, request):
69+
cases = (
70+
('python.bmp', ('.bmp', CT.BMP, 211, 71, 72, 72)),
71+
('sonic.gif', ('.gif', CT.GIF, 290, 360, 72, 72)),
72+
('python-icon.jpeg', ('.jpg', CT.JPEG, 204, 204, 72, 72)),
73+
('300-dpi.jpg', ('.jpg', CT.JPEG, 1504, 1936, 300, 300)),
74+
('monty-truth.png', ('.png', CT.PNG, 150, 214, 72, 72)),
75+
('150-dpi.png', ('.png', CT.PNG, 901, 1350, 150, 150)),
76+
('300-dpi.png', ('.png', CT.PNG, 860, 579, 300, 300)),
77+
('72-dpi.tiff', ('.tiff', CT.TIFF, 48, 48, 72, 72)),
78+
('300-dpi.TIF', ('.tiff', CT.TIFF, 2464, 3248, 300, 300)),
79+
('CVS_LOGO.WMF', ('.wmf', CT.X_WMF, 149, 59, 72, 72)),
80+
)
81+
image_filename, characteristics = cases[request.param]
82+
return image_filename, characteristics
3883

3984

4085
class DescribeImagePart(object):

tests/test_files/150-dpi.png

143 KB
Loading

tests/test_files/300-dpi.TIF

123 KB
Binary file not shown.

tests/test_files/300-dpi.jpg

347 KB
Loading

tests/test_files/300-dpi.png

39 KB
Loading

tests/test_files/72-dpi.tiff

9.23 KB
Binary file not shown.

tests/test_files/CVS_LOGO.WMF

1.49 KB
Binary file not shown.

tests/test_files/sonic.gif

32.5 KB
Loading

0 commit comments

Comments
 (0)