Coverage for src/lektor_ng/imagetools/image_info.py: 99%
91 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 14:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-05 14:42 +0000
1"""Helper to probe basic image information: dimensions and format."""
3from __future__ import annotations
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
14import PIL.Image
16from ._compat import ExifTags, UnidentifiedImageError
18if TYPE_CHECKING:
19 from typing import Literal
21 from _typeshed import SupportsRead
24class SvgImageInfo(NamedTuple):
25 format: Literal["svg"] = "svg"
26 width: float | None = None
27 height: float | None = None
30class PILImageInfo(NamedTuple):
31 format: str
32 width: int
33 height: int
36class UnknownImageInfo(NamedTuple):
37 format: None = None
38 width: None = None
39 height: None = None
42ImageInfo: TypeAlias = PILImageInfo | SvgImageInfo | UnknownImageInfo
45class TiffOrientation(enum.IntEnum):
46 """The possible values of the "Exif Orientation" tag."""
48 TOPLEFT = 1
49 TOPRIGHT = 2
50 BOTRIGHT = 3
51 BOTLEFT = 4
52 LEFTTOP = 5
53 RIGHTTOP = 6
54 RIGHTBOT = 7
55 LEFTBOT = 8
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}
62def get_image_orientation(image: PIL.Image.Image) -> TiffOrientation:
63 """Deduce the orientation of the image.
65 Notes
66 -----
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.)
73 __ https://zpl.fi/exif-orientation-in-different-formats/
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.
81 __ https://ftp-osl.osuosl.org/pub/libpng/documents/proposals/eXIf/png-proposed-eXIf-chunk-2017-06-15.html
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.
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.)
93 For all of these reasons, we only check the Exif Orientation tag for JPEGs.
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
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
113class BadSvgFile(Exception):
114 """Exception raised when SVG file can not be parsed."""
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)
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}
139def _PIL_image_info(
140 image: PIL.Image.Image,
141) -> PILImageInfo | UnknownImageInfo:
142 """Determine image format and dimensions for PIL Image"""
144 assert image.format is not None
145 try:
146 lektor_fmt = _LEKTOR_FORMATS[image.format]
147 except LookupError:
148 return UnknownImageInfo()
150 width = image.width
151 height = image.height
153 orientation = get_image_orientation(image)
154 if orientation.is_transposed:
155 width, height = height, width
157 return PILImageInfo(lektor_fmt, width, height)
160@contextmanager
161def _save_position(fp: BinaryIO) -> Generator[BinaryIO]:
162 position = fp.tell()
163 try:
164 yield fp
165 finally:
166 fp.seek(position)
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))
181 image = stack.enter_context(PIL.Image.open(source))
182 return _PIL_image_info(image)
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)
189 return UnknownImageInfo()