Mercurial > libervia-backend
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 | 0d7bb4df2343 |
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 |