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

1"""Image utilities. 

2 

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""" 

7 

8import io 

9from typing import Optional 

10 

11import PIL.Image 

12import PIL.ImageDraw 

13import PIL.ImageFont 

14import qrcode.main 

15import qrcode.constants 

16 

17import gws 

18import gws.lib.mime 

19 

20from . import core, format 

21 

22 

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. 

31 

32 The image is scaled to fit into ``size``, keeping the aspect ratio. Small images are not scaled up. 

33 

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``. 

40 

41 Returns: 

42 The encoded thumbnail. 

43 

44 Raises: 

45 ``Error``: If the image is too big or cannot be processed. 

46 """ 

47 

48 w, h = size 

49 sz = int(w), int(h) 

50 

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 

66 

67 

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. 

77 

78 See https://github.com/lincolnloop/python-qrcode/blob/main/README.rst#advanced-usage. 

79 

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. 

87 

88 Returns: 

89 An image object. 

90 

91 Raises: 

92 ``Error``: If the image cannot be created. 

93 """ 

94 

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 } 

101 

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 ) 

108 

109 qr.add_data(data) 

110 qr.make(fit=True) 

111 

112 img = qr.make_image(fill_color=color, back_color=background) 

113 return core._new(img) 

114 

115 

116def get_draw(img: core.Image) -> PIL.ImageDraw.ImageDraw: 

117 """Return a PIL drawing object for an image. 

118 

119 Args: 

120 img: An image object. 

121 

122 Returns: 

123 A ``PIL.ImageDraw.ImageDraw`` that draws on the image in place. 

124 """ 

125 

126 return PIL.ImageDraw.Draw(img.img) 

127 

128 

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. 

134 

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. 

138 

139 Returns: 

140 A PIL font object. 

141 """ 

142 

143 if font: 

144 return PIL.ImageFont.truetype(font, size) 

145 return PIL.ImageFont.load_default() 

146 

147 

148_PIXELS = {} 

149_ERROR_COLOR = '#ffa1b4' 

150_EMPTY_COLOR = '#ffffff' 

151 

152 

153def empty_pixel(ifo: Optional[gws.ImageFormat] = None): 

154 """Return an encoded empty 1x1 image. 

155 

156 The pixel is transparent, or the background color of the format (white by default) for JPEG. 

157 

158 Args: 

159 ifo: Image format, PNG24 by default. 

160 

161 Returns: 

162 The image encoded in the first MIME type of the format. 

163 

164 Raises: 

165 ``Error``: If the MIME type is not an image type. 

166 """ 

167 

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) 

173 

174 

175def error_pixel(ifo: Optional[gws.ImageFormat] = None): 

176 """Return an encoded 1x1 image in the error color. 

177 

178 Args: 

179 ifo: Image format, PNG24 by default. 

180 

181 Returns: 

182 The image encoded in the first MIME type of the format. 

183 

184 Raises: 

185 ``Error``: If the MIME type is not an image type. 

186 """ 

187 

188 ifo = ifo or format.PNG24 

189 return pixel(ifo.mimeTypes[0], _ERROR_COLOR) 

190 

191 

192def pixel(mime: gws.MimeType, color): 

193 """Return an encoded 1x1 image of a color. 

194 

195 Results are cached. 

196 

197 Args: 

198 mime: MIME type, PNG by default. 

199 color: Pixel color, or ``None`` for a transparent pixel. 

200 

201 Returns: 

202 The encoded image. 

203 

204 Raises: 

205 ``Error``: If the MIME type is not an image type. 

206 """ 

207 

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) 

212 

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() 

218 

219 return _PIXELS[key]