comparison libervia/backend/tools/image.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/tools/image.py@524856bd7b19
children
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Libervia: an XMPP client
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 """Methods to manipulate images"""
20
21 import tempfile
22 import mimetypes
23 from PIL import Image, ImageOps
24 from pathlib import Path
25 from twisted.internet import threads
26 from libervia.backend.core.i18n import _
27 from libervia.backend.core import exceptions
28 from libervia.backend.core.log import getLogger
29
30 log = getLogger(__name__)
31
32 try:
33 import cairosvg
34 except Exception as e:
35 log.warning(_("SVG support not available, please install cairosvg: {e}").format(
36 e=e))
37 cairosvg = None
38
39
40 def check(host, path, max_size=None):
41 """Analyze image and return a report
42
43 report will indicate if image is too large, and the recommended new size if this is
44 the case
45 @param host: SàT instance
46 @param path(str, pathlib.Path): image to open
47 @param max_size(tuple[int, int]): maximum accepted size of image
48 None to use value set in config
49 @return dict: report on image, with following keys:
50 - too_large: true if image is oversized
51 - recommended_size: if too_large is True, recommended size to use
52 """
53 report = {}
54 image = Image.open(path)
55 if max_size is None:
56 max_size = tuple(host.memory.config_get(None, "image_max", (1200, 720)))
57 if image.size > max_size:
58 report['too_large'] = True
59 if image.size[0] > max_size[0]:
60 factor = max_size[0] / image.size[0]
61 if image.size[1] * factor > max_size[1]:
62 factor = max_size[1] / image.size[1]
63 else:
64 factor = max_size[1] / image.size[1]
65 report['recommended_size'] = [int(image.width*factor), int(image.height*factor)]
66 else:
67 report['too_large'] = False
68
69 return report
70
71
72 def _resize_blocking(image_path, new_size, dest, fix_orientation):
73 im_path = Path(image_path)
74 im = Image.open(im_path)
75 resized = im.resize(new_size, Image.LANCZOS)
76 if fix_orientation:
77 resized = ImageOps.exif_transpose(resized)
78
79 if dest is None:
80 dest = tempfile.NamedTemporaryFile(suffix=im_path.suffix, delete=False)
81 elif isinstance(dest, Path):
82 dest = dest.open('wb')
83
84 with dest as f:
85 resized.save(f, format=im.format)
86
87 return Path(f.name)
88
89
90 def resize(image_path, new_size, dest=None, fix_orientation=True):
91 """Resize an image to a new file, and return its path
92
93 @param image_path(str, Path): path of the original image
94 @param new_size(tuple[int, int]): size to use for new image
95 @param dest(None, Path, file): where the resized image must be stored, can be:
96 - None: use a temporary file
97 file will be converted to PNG
98 - Path: path to the file to create/overwrite
99 - file: a file object which must be opened for writing in binary mode
100 @param fix_orientation: if True, use EXIF data to set orientation
101 @return (Path): path of the resized file.
102 The image at this path should be deleted after use
103 """
104 return threads.deferToThread(
105 _resize_blocking, image_path, new_size, dest, fix_orientation)
106
107
108 def _convert_blocking(image_path, dest, extra):
109 media_type = mimetypes.guess_type(str(image_path), strict=False)[0]
110
111 if dest is None:
112 dest = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
113 filepath = Path(dest.name)
114 elif isinstance(dest, Path):
115 filepath = dest
116 else:
117 # we should have a file-like object
118 try:
119 name = dest.name
120 except AttributeError:
121 name = None
122 if name:
123 try:
124 filepath = Path(name)
125 except TypeError:
126 filepath = Path('noname.png')
127 else:
128 filepath = Path('noname.png')
129
130 if media_type == "image/svg+xml":
131 if cairosvg is None:
132 raise exceptions.MissingModule(
133 f"Can't convert SVG image at {image_path} due to missing CairoSVG module")
134 width, height = extra.get('width'), extra.get('height')
135 cairosvg.svg2png(
136 url=str(image_path), write_to=dest,
137 output_width=width, output_height=height
138 )
139 else:
140 suffix = filepath.suffix
141 if not suffix:
142 raise ValueError(
143 "A suffix is missing for destination, it is needed to determine file "
144 "format")
145 if not suffix in Image.EXTENSION:
146 Image.init()
147 try:
148 im_format = Image.EXTENSION[suffix]
149 except KeyError:
150 raise ValueError(
151 "Dest image format can't be determined, {suffix!r} suffix is unknown"
152 )
153 im = Image.open(image_path)
154 im.save(dest, format=im_format)
155
156 log.debug(f"image {image_path} has been converted to {filepath}")
157 return filepath
158
159
160 def convert(image_path, dest=None, extra=None):
161 """Convert an image to a new file, and return its path
162
163 @param image_path(str, Path): path of the image to convert
164 @param dest(None, Path, file): where the converted image must be stored, can be:
165 - None: use a temporary file
166 - Path: path to the file to create/overwrite
167 - file: a file object which must be opened for writing in binary mode
168 @param extra(None, dict): conversion options
169 if image_path link to a SVG file, following options can be used:
170 - width: destination width
171 - height: destination height
172 @return (Path): path of the converted file.
173 a generic name is used if dest is an unnamed file like object
174 """
175 image_path = Path(image_path)
176 if not image_path.is_file():
177 raise ValueError(f"There is no file at {image_path}!")
178 if extra is None:
179 extra = {}
180 return threads.deferToThread(_convert_blocking, image_path, dest, extra)
181
182
183 def __fix_orientation_blocking(image_path):
184 im = Image.open(image_path)
185 im_format = im.format
186 exif = im.getexif()
187 orientation = exif.get(0x0112)
188 if orientation is None or orientation<2:
189 # nothing to do
190 return False
191 im = ImageOps.exif_transpose(im)
192 im.save(image_path, im_format)
193 log.debug(f"image {image_path} orientation has been fixed")
194 return True
195
196
197 def fix_orientation(image_path: Path) -> bool:
198 """Apply orientation found in EXIF data if any
199
200 @param image_path: image location, image will be modified in place
201 @return True if image has been modified
202 """
203 return threads.deferToThread(__fix_orientation_blocking, image_path)
204
205
206 def guess_type(source):
207 """Guess image media type
208
209 @param source(str, Path, file): image to guess type
210 @return (str, None): media type, or None if we can't guess
211 """
212 if isinstance(source, str):
213 source = Path(source)
214
215 if isinstance(source, Path):
216 # we first try to guess from file name
217 media_type = mimetypes.guess_type(source, strict=False)[0]
218 if media_type is not None:
219 return media_type
220
221 # file name is not enough, we try to open it
222 img = Image.open(source)
223 try:
224 return Image.MIME[img.format]
225 except KeyError:
226 return None