Coverage for src/lektor_ng/imagetools/image_info.py: 99%

91 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-08 23:29 +0000

1"""Helper to probe basic image information: dimensions and format.""" 

2 

3from __future__ import annotations 

4 

5import enum 

6import re 

7import warnings 

8from collections.abc import Generator, Mapping 

9from contextlib import ExitStack, contextmanager, suppress 

10from pathlib import Path 

11from typing import TYPE_CHECKING, BinaryIO, Final, NamedTuple, TypeAlias 

12from xml.etree import ElementTree as etree 

13 

14import PIL.Image 

15 

16from ._compat import ExifTags, UnidentifiedImageError 

17 

18if TYPE_CHECKING: 

19 from typing import Literal 

20 

21 from _typeshed import SupportsRead 

22 

23 

24class SvgImageInfo(NamedTuple): 

25 format: Literal["svg"] = "svg" 

26 width: float | None = None 

27 height: float | None = None 

28 

29 

30class PILImageInfo(NamedTuple): 

31 format: str 

32 width: int 

33 height: int 

34 

35 

36class UnknownImageInfo(NamedTuple): 

37 format: None = None 

38 width: None = None 

39 height: None = None 

40 

41 

42ImageInfo: TypeAlias = PILImageInfo | SvgImageInfo | UnknownImageInfo 

43 

44 

45class TiffOrientation(enum.IntEnum): 

46 """The possible values of the "Exif Orientation" tag.""" 

47 

48 TOPLEFT = 1 

49 TOPRIGHT = 2 

50 BOTRIGHT = 3 

51 BOTLEFT = 4 

52 LEFTTOP = 5 

53 RIGHTTOP = 6 

54 RIGHTBOT = 7 

55 LEFTBOT = 8 

56 

57 def __init__(self, value: int): 

58 # True if orientation implies width and height are transposed 

59 self.is_transposed = value in {5, 6, 7, 8} 

60 

61 

62def get_image_orientation(image: PIL.Image.Image) -> TiffOrientation: 

63 """Deduce the orientation of the image. 

64 

65 Notes 

66 ----- 

67 

68 Note that browsers only seem to respect the "Exif" Orientation tag for JPEG images 

69 (and probably TIFF images). In particular, it is `typically ignored`__ by browsers 

70 when displaying PNG, WEBP and AVIF files (though AVIF files have their own way of 

71 indicating orientation — the "irot" and "imir" properties — which are respected.) 

72 

73 __ https://zpl.fi/exif-orientation-in-different-formats/ 

74 

75 Exif information can be stored in PNG files, however the `spec for Exif in PNG`__ 

76 does state that the Exif information should be considered "historical", under the 

77 assumption that it was probably copied directly from the source image (where it was 

78 written by, e.g., the camera). It implies that the "unsafe-to-copy" Exif information 

79 (e.g. orientation, size) should be ignored. 

80 

81 __ https://ftp-osl.osuosl.org/pub/libpng/documents/proposals/eXIf/png-proposed-eXIf-chunk-2017-06-15.html 

82 

83 Prior to Lektor 3.4, Lektor's ``get_image_info`` only checked the orientation for 

84 JPEG images (transposing width↔height when appropriate). The ``-auto-orient`` 

85 option to ImageMagick's ``convert`` appears to ignore Exif Orientation in PNG files, 

86 too. 

87 

88 Finally, note that reading Exif information from PNG files using Pillow is a slow 

89 operation. It seems to require loading and decoding the full image. (Loading Exif 

90 information from JPEG files does not require decoding the image, so is much 

91 quicker.) 

92 

93 For all of these reasons, we only check the Exif Orientation tag for JPEGs. 

94 

95 """ # pylint: disable=line-too-long 

96 if image.format != "JPEG": 

97 return TiffOrientation.TOPLEFT 

98 exif = image.getexif() 

99 try: 

100 orientation = exif[ExifTags.Base.Orientation] 

101 return TiffOrientation(orientation) 

102 except (ValueError, LookupError): 

103 return TiffOrientation.TOPLEFT 

104 

105 

106def _parse_svg_units_px(length: str) -> float | None: 

107 match = re.match(r"\d+(?: \.\d* )? (?= (?: \s*px )? \Z)", length.strip(), re.VERBOSE) 

108 if match: 

109 return float(match.group()) 

110 return None 

111 

112 

113class BadSvgFile(Exception): 

114 """Exception raised when SVG file can not be parsed.""" 

115 

116 

117def _get_svg_info( 

118 source: str | Path | SupportsRead[bytes], 

119) -> SvgImageInfo | UnknownImageInfo: 

120 try: 

121 _, svg = next(etree.iterparse(source, events=["start"])) 

122 except (etree.ParseError, StopIteration) as exc: 

123 raise BadSvgFile("can not parse SVG file") from exc 

124 if svg.tag != "{http://www.w3.org/2000/svg}svg": 

125 raise BadSvgFile("unknown tag in SVG file") 

126 width = _parse_svg_units_px(svg.attrib.get("width", "")) 

127 height = _parse_svg_units_px(svg.attrib.get("height", "")) 

128 return SvgImageInfo("svg", width, height) 

129 

130 

131# Mapping from PIL format to Lektor format 

132_LEKTOR_FORMATS: Final[Mapping[str, str]] = { 

133 "PNG": "png", 

134 "GIF": "gif", 

135 "JPEG": "jpeg", 

136} 

137 

138 

139def _PIL_image_info( 

140 image: PIL.Image.Image, 

141) -> PILImageInfo | UnknownImageInfo: 

142 """Determine image format and dimensions for PIL Image""" 

143 

144 assert image.format is not None 

145 try: 

146 lektor_fmt = _LEKTOR_FORMATS[image.format] 

147 except LookupError: 

148 return UnknownImageInfo() 

149 

150 width = image.width 

151 height = image.height 

152 

153 orientation = get_image_orientation(image) 

154 if orientation.is_transposed: 

155 width, height = height, width 

156 

157 return PILImageInfo(lektor_fmt, width, height) 

158 

159 

160@contextmanager 

161def _save_position(fp: BinaryIO) -> Generator[BinaryIO]: 

162 position = fp.tell() 

163 try: 

164 yield fp 

165 finally: 

166 fp.seek(position) 

167 

168 

169def get_image_info(source: str | Path | BinaryIO) -> ImageInfo: 

170 """Determine type and dimensions of an image file.""" 

171 with suppress(UnidentifiedImageError), ExitStack() as stack: 

172 if not isinstance(source, (str, Path)): 

173 warnings.warn( 

174 "Passing a file object to 'get_image_info' is deprecated " 

175 "since version 3.4.0. Pass a file path instead.", 

176 DeprecationWarning, 

177 stacklevel=2, 

178 ) 

179 stack.enter_context(_save_position(source)) 

180 

181 image = stack.enter_context(PIL.Image.open(source)) 

182 return _PIL_image_info(image) 

183 

184 with suppress(BadSvgFile), ExitStack() as stack: 

185 if not isinstance(source, (str, Path)): 

186 stack.enter_context(_save_position(source)) 

187 return _get_svg_info(source) 

188 

189 return UnknownImageInfo()