Coverage for gws-app/gws/lib/image/util.py: 78%
64 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Image utilities.
3``thumbnail`` scales encoded images down, ``pixel``, ``empty_pixel`` and ``error_pixel`` return
4cached encoded 1x1 images. ``qr_code`` creates an image with a QR code.
5``get_draw`` and ``get_font`` give access to PIL drawing on an image.
6"""
8import io
9from typing import Optional
11import PIL.Image
12import PIL.ImageDraw
13import PIL.ImageFont
14import qrcode.main
15import qrcode.constants
17import gws
18import gws.lib.mime
20from . import core, format
23def thumbnail(
24 r: bytes,
25 size: gws.Size,
26 max_pixels=0,
27 mime: gws.MimeType = '',
28 options=None,
29) -> bytes:
30 """Create a thumbnail from an encoded image.
32 The image is scaled to fit into ``size``, keeping the aspect ratio. Small images are not scaled up.
34 Args:
35 r: Encoded image.
36 size: Maximum thumbnail size ``(width, height)``.
37 max_pixels: Maximum number of source pixels, ``0`` for no limit.
38 mime: MIME type of the thumbnail, PNG by default.
39 options: Encoding options, as in ``Image.to_bytes``.
41 Returns:
42 The encoded thumbnail.
44 Raises:
45 ``Error``: If the image is too big or cannot be processed.
46 """
48 w, h = size
49 sz = int(w), int(h)
51 try:
52 with io.BytesIO(r) as fp:
53 img = PIL.Image.open(fp)
54 w, h = img.size
55 if max_pixels and w * h > max_pixels:
56 raise core.Error(f'image too big: {w}x{h}')
57 img.draft(img.mode, sz)
58 img.thumbnail(sz, resample=PIL.Image.Resampling.BICUBIC)
59 if img.mode not in {'1', 'L', 'LA', 'P', 'RGB', 'RGBA'}:
60 img = img.convert('RGB')
61 return core.Image(img).to_bytes(mime, options)
62 except core.Error:
63 raise
64 except Exception as exc:
65 raise core.Error from exc
68def qr_code(
69 data: str,
70 level='M',
71 scale=4,
72 border=True,
73 color='black',
74 background='white',
75) -> core.Image:
76 """Create an image with a QR code.
78 See https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage.
80 Args:
81 data: Data to encode.
82 level: Error correction level, one of ``L``, ``M``, ``Q``, ``H``.
83 scale: Box size in pixels.
84 border: If ``True``, include a quiet zone of 4 boxes.
85 color: Foreground color.
86 background: Background color.
88 Returns:
89 An image object.
91 Raises:
92 ``Error``: If the image cannot be created.
93 """
95 ec_map = {
96 'L': qrcode.constants.ERROR_CORRECT_L,
97 'M': qrcode.constants.ERROR_CORRECT_M,
98 'Q': qrcode.constants.ERROR_CORRECT_Q,
99 'H': qrcode.constants.ERROR_CORRECT_H,
100 }
102 qr = qrcode.main.QRCode(
103 version=None,
104 error_correction=ec_map[level],
105 box_size=scale,
106 border=4 if border else 0,
107 )
109 qr.add_data(data)
110 qr.make(fit=True)
112 img = qr.make_image(fill_color=color, back_color=background)
113 return core._new(img)
116def get_draw(img: core.Image) -> PIL.ImageDraw.ImageDraw:
117 """Return a PIL drawing object for an image.
119 Args:
120 img: An image object.
122 Returns:
123 A ``PIL.ImageDraw.ImageDraw`` that draws on the image in place.
124 """
126 return PIL.ImageDraw.Draw(img.img)
129def get_font(
130 size: int = 12,
131 font: Optional[str] = None,
132) -> PIL.ImageFont.ImageFont | PIL.ImageFont.FreeTypeFont:
133 """Return a PIL font object.
135 Args:
136 size: Font size, used for TrueType fonts only.
137 font: Path to a TrueType font file, or ``None`` for the PIL default font.
139 Returns:
140 A PIL font object.
141 """
143 if font:
144 return PIL.ImageFont.truetype(font, size)
145 return PIL.ImageFont.load_default()
148_PIXELS = {}
149_ERROR_COLOR = '#ffa1b4'
150_EMPTY_COLOR = '#ffffff'
153def empty_pixel(ifo: Optional[gws.ImageFormat] = None):
154 """Return an encoded empty 1x1 image.
156 The pixel is transparent, or the background color of the format (white by default) for JPEG.
158 Args:
159 ifo: Image format, PNG24 by default.
161 Returns:
162 The image encoded in the first MIME type of the format.
164 Raises:
165 ``Error``: If the MIME type is not an image type.
166 """
168 ifo = ifo or format.PNG24
169 mime = ifo.mimeTypes[0]
170 if gws.lib.mime.get_base(mime) == gws.lib.mime.JPEG:
171 return pixel(mime, ifo.options.get('background') or _EMPTY_COLOR)
172 return pixel(mime, None)
175def error_pixel(ifo: Optional[gws.ImageFormat] = None):
176 """Return an encoded 1x1 image in the error color.
178 Args:
179 ifo: Image format, PNG24 by default.
181 Returns:
182 The image encoded in the first MIME type of the format.
184 Raises:
185 ``Error``: If the MIME type is not an image type.
186 """
188 ifo = ifo or format.PNG24
189 return pixel(ifo.mimeTypes[0], _ERROR_COLOR)
192def pixel(mime: gws.MimeType, color):
193 """Return an encoded 1x1 image of a color.
195 Results are cached.
197 Args:
198 mime: MIME type, PNG by default.
199 color: Pixel color, or ``None`` for a transparent pixel.
201 Returns:
202 The encoded image.
204 Raises:
205 ``Error``: If the MIME type is not an image type.
206 """
208 fmt = core.mime_to_pil_format(mime)
209 if not fmt:
210 raise core.Error(f'unknown mime type {mime!r}')
211 key = fmt, str(color)
213 if key not in _PIXELS:
214 img = PIL.Image.new('RGBA' if color is None else 'RGB', (1, 1), color)
215 with io.BytesIO() as fp:
216 img.save(fp, fmt)
217 _PIXELS[key] = fp.getvalue()
219 return _PIXELS[key]