models.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181
  1. import hashlib
  2. import logging
  3. import os.path
  4. import time
  5. from collections import OrderedDict, defaultdict
  6. from concurrent.futures import ThreadPoolExecutor
  7. from contextlib import contextmanager
  8. from io import BytesIO
  9. from tempfile import SpooledTemporaryFile
  10. from typing import Dict, Iterable, List, Union
  11. import willow
  12. from django.apps import apps
  13. from django.conf import settings
  14. from django.core import checks
  15. from django.core.cache import DEFAULT_CACHE_ALIAS, InvalidCacheBackendError, caches
  16. from django.core.cache.backends.base import BaseCache
  17. from django.core.files import File
  18. from django.core.files.base import ContentFile
  19. from django.core.files.storage import default_storage
  20. from django.db import models
  21. from django.db.models import Q
  22. from django.forms.utils import flatatt
  23. from django.urls import reverse
  24. from django.utils.functional import cached_property, classproperty
  25. from django.utils.module_loading import import_string
  26. from django.utils.safestring import mark_safe
  27. from django.utils.translation import gettext_lazy as _
  28. from taggit.managers import TaggableManager
  29. from wagtail import hooks
  30. from wagtail.coreutils import string_to_ascii
  31. from wagtail.images.exceptions import (
  32. InvalidFilterSpecError,
  33. UnknownOutputImageFormatError,
  34. )
  35. from wagtail.images.image_operations import (
  36. FilterOperation,
  37. ImageTransform,
  38. TransformOperation,
  39. )
  40. from wagtail.images.rect import Rect
  41. from wagtail.models import CollectionMember, ReferenceIndex
  42. from wagtail.search import index
  43. from wagtail.search.queryset import SearchableQuerySetMixin
  44. from wagtail.utils.file import hash_filelike
  45. logger = logging.getLogger("wagtail.images")
  46. IMAGE_FORMAT_EXTENSIONS = {
  47. "avif": ".avif",
  48. "jpeg": ".jpg",
  49. "png": ".png",
  50. "gif": ".gif",
  51. "webp": ".webp",
  52. "svg": ".svg",
  53. }
  54. class SourceImageIOError(IOError):
  55. """
  56. Custom exception to distinguish IOErrors that were thrown while opening the source image
  57. """
  58. pass
  59. class ImageQuerySet(SearchableQuerySetMixin, models.QuerySet):
  60. def prefetch_renditions(self, *filters):
  61. """
  62. Prefetches generated renditions for the given filters.
  63. Returns all renditions when no filters are provided.
  64. """
  65. rendition_model = self.model.get_rendition_model()
  66. queryset = rendition_model.objects.all()
  67. if filters:
  68. # Get a list of filter spec strings. The given value could contain Filter objects
  69. filter_specs = [
  70. filter.spec if isinstance(filter, Filter) else filter
  71. for filter in filters
  72. ]
  73. queryset = queryset.filter(filter_spec__in=filter_specs)
  74. return self.prefetch_related(
  75. models.Prefetch(
  76. "renditions",
  77. queryset=queryset,
  78. to_attr="prefetched_renditions",
  79. )
  80. )
  81. def get_upload_to(instance, filename):
  82. """
  83. Obtain a valid upload path for an image file.
  84. This needs to be a module-level function so that it can be referenced within migrations,
  85. but simply delegates to the `get_upload_to` method of the instance, so that AbstractImage
  86. subclasses can override it.
  87. """
  88. return instance.get_upload_to(filename)
  89. def get_rendition_upload_to(instance, filename):
  90. """
  91. Obtain a valid upload path for an image rendition file.
  92. This needs to be a module-level function so that it can be referenced within migrations,
  93. but simply delegates to the `get_upload_to` method of the instance, so that AbstractRendition
  94. subclasses can override it.
  95. """
  96. return instance.get_upload_to(filename)
  97. def get_rendition_storage():
  98. """
  99. Obtain the storage object for an image rendition file.
  100. Returns custom storage (if defined), or the default storage.
  101. This needs to be a module-level function, because we do not yet
  102. have an instance when Django loads the models.
  103. """
  104. storage = getattr(settings, "WAGTAILIMAGES_RENDITION_STORAGE", default_storage)
  105. if isinstance(storage, str):
  106. module = import_string(storage)
  107. storage = module()
  108. return storage
  109. class ImageFileMixin:
  110. def is_stored_locally(self):
  111. """
  112. Returns True if the image is hosted on the local filesystem
  113. """
  114. try:
  115. self.file.path
  116. return True
  117. except NotImplementedError:
  118. return False
  119. def get_file_size(self):
  120. if self.file_size is None:
  121. try:
  122. self.file_size = self.file.size
  123. except Exception as e: # noqa: BLE001
  124. # File not found
  125. #
  126. # Have to catch everything, because the exception
  127. # depends on the file subclass, and therefore the
  128. # storage being used.
  129. raise SourceImageIOError(str(e))
  130. self.save(update_fields=["file_size"])
  131. return self.file_size
  132. @contextmanager
  133. def open_file(self):
  134. # Open file if it is closed
  135. close_file = False
  136. try:
  137. image_file = self.file
  138. if self.file.closed:
  139. # Reopen the file
  140. if self.is_stored_locally():
  141. self.file.open("rb")
  142. else:
  143. # Some external storage backends don't allow reopening
  144. # the file. Get a fresh file instance. #1397
  145. storage = self._meta.get_field("file").storage
  146. image_file = storage.open(self.file.name, "rb")
  147. close_file = True
  148. except OSError as e:
  149. # re-throw this as a SourceImageIOError so that calling code can distinguish
  150. # these from IOErrors elsewhere in the process
  151. raise SourceImageIOError(str(e))
  152. # Seek to beginning
  153. image_file.seek(0)
  154. try:
  155. yield image_file
  156. finally:
  157. if close_file:
  158. image_file.close()
  159. @contextmanager
  160. def get_willow_image(self):
  161. with self.open_file() as image_file:
  162. yield willow.Image.open(image_file)
  163. class WagtailImageFieldFile(models.fields.files.ImageFieldFile):
  164. """
  165. Override the ImageFieldFile in order to use Willow instead
  166. of Pillow.
  167. """
  168. def _get_image_dimensions(self):
  169. """
  170. override _get_image_dimensions to call our own get_image_dimensions.
  171. """
  172. if not hasattr(self, "_dimensions_cache"):
  173. self._dimensions_cache = self.get_image_dimensions()
  174. return self._dimensions_cache
  175. def get_image_dimensions(self):
  176. """
  177. The upstream ImageFieldFile calls a local function get_image_dimensions. In this implementation we've made get_image_dimensions
  178. a method to make it easier to override for Wagtail developers in the future.
  179. """
  180. close = self.closed
  181. try:
  182. self.open()
  183. image = willow.Image.open(self)
  184. return image.get_size()
  185. finally:
  186. if close:
  187. self.close()
  188. else:
  189. self.seek(0)
  190. class WagtailImageField(models.ImageField):
  191. """
  192. Override the attr_class on the Django ImageField Model to inject our ImageFieldFile
  193. with Willow support.
  194. """
  195. attr_class = WagtailImageFieldFile
  196. class AbstractImage(ImageFileMixin, CollectionMember, index.Indexed, models.Model):
  197. title = models.CharField(max_length=255, verbose_name=_("title"))
  198. """ Use local ImageField with Willow support. """
  199. file = WagtailImageField(
  200. verbose_name=_("file"),
  201. upload_to=get_upload_to,
  202. width_field="width",
  203. height_field="height",
  204. )
  205. width = models.IntegerField(verbose_name=_("width"), editable=False)
  206. height = models.IntegerField(verbose_name=_("height"), editable=False)
  207. created_at = models.DateTimeField(
  208. verbose_name=_("created at"), auto_now_add=True, db_index=True
  209. )
  210. uploaded_by_user = models.ForeignKey(
  211. settings.AUTH_USER_MODEL,
  212. verbose_name=_("uploaded by user"),
  213. null=True,
  214. blank=True,
  215. editable=False,
  216. on_delete=models.SET_NULL,
  217. )
  218. uploaded_by_user.wagtail_reference_index_ignore = True
  219. tags = TaggableManager(help_text=None, blank=True, verbose_name=_("tags"))
  220. focal_point_x = models.PositiveIntegerField(null=True, blank=True)
  221. focal_point_y = models.PositiveIntegerField(null=True, blank=True)
  222. focal_point_width = models.PositiveIntegerField(null=True, blank=True)
  223. focal_point_height = models.PositiveIntegerField(null=True, blank=True)
  224. file_size = models.PositiveIntegerField(null=True, editable=False)
  225. # A SHA-1 hash of the file contents
  226. file_hash = models.CharField(
  227. max_length=40, blank=True, editable=False, db_index=True
  228. )
  229. objects = ImageQuerySet.as_manager()
  230. def _set_file_hash(self):
  231. with self.open_file() as f:
  232. self.file_hash = hash_filelike(f)
  233. def get_file_hash(self):
  234. if self.file_hash == "":
  235. self._set_file_hash()
  236. self.save(update_fields=["file_hash"])
  237. return self.file_hash
  238. def _set_image_file_metadata(self):
  239. self.file.open()
  240. # Set new image file size
  241. self.file_size = self.file.size
  242. # Set new image file hash
  243. self._set_file_hash()
  244. self.file.seek(0)
  245. def get_upload_to(self, filename):
  246. folder_name = "original_images"
  247. filename = self.file.field.storage.get_valid_name(filename)
  248. # convert the filename to simple ascii characters and then
  249. # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding
  250. filename = "".join(
  251. (i if ord(i) < 128 else "_") for i in string_to_ascii(filename)
  252. )
  253. # Truncate filename so it fits in the 100 character limit
  254. # https://code.djangoproject.com/ticket/9893
  255. full_path = os.path.join(folder_name, filename)
  256. if len(full_path) >= 95:
  257. chars_to_trim = len(full_path) - 94
  258. prefix, extension = os.path.splitext(filename)
  259. filename = prefix[:-chars_to_trim] + extension
  260. full_path = os.path.join(folder_name, filename)
  261. return full_path
  262. def get_usage(self):
  263. return ReferenceIndex.get_grouped_references_to(self)
  264. @property
  265. def usage_url(self):
  266. return reverse("wagtailimages:image_usage", args=(self.id,))
  267. search_fields = CollectionMember.search_fields + [
  268. index.SearchField("title", boost=10),
  269. index.AutocompleteField("title"),
  270. index.FilterField("title"),
  271. index.RelatedFields(
  272. "tags",
  273. [
  274. index.SearchField("name", boost=10),
  275. index.AutocompleteField("name"),
  276. ],
  277. ),
  278. index.FilterField("uploaded_by_user"),
  279. ]
  280. def __str__(self):
  281. return self.title
  282. def get_rect(self):
  283. return Rect(0, 0, self.width, self.height)
  284. def get_focal_point(self):
  285. if (
  286. self.focal_point_x is not None
  287. and self.focal_point_y is not None
  288. and self.focal_point_width is not None
  289. and self.focal_point_height is not None
  290. ):
  291. return Rect.from_point(
  292. self.focal_point_x,
  293. self.focal_point_y,
  294. self.focal_point_width,
  295. self.focal_point_height,
  296. )
  297. def has_focal_point(self):
  298. return self.get_focal_point() is not None
  299. def set_focal_point(self, rect):
  300. if rect is not None:
  301. self.focal_point_x = rect.centroid_x
  302. self.focal_point_y = rect.centroid_y
  303. self.focal_point_width = rect.width
  304. self.focal_point_height = rect.height
  305. else:
  306. self.focal_point_x = None
  307. self.focal_point_y = None
  308. self.focal_point_width = None
  309. self.focal_point_height = None
  310. def get_suggested_focal_point(self):
  311. with self.get_willow_image() as willow:
  312. faces = willow.detect_faces()
  313. if faces:
  314. # Create a bounding box around all faces
  315. left = min(face[0] for face in faces)
  316. top = min(face[1] for face in faces)
  317. right = max(face[2] for face in faces)
  318. bottom = max(face[3] for face in faces)
  319. focal_point = Rect(left, top, right, bottom)
  320. else:
  321. features = willow.detect_features()
  322. if features:
  323. # Create a bounding box around all features
  324. left = min(feature[0] for feature in features)
  325. top = min(feature[1] for feature in features)
  326. right = max(feature[0] for feature in features)
  327. bottom = max(feature[1] for feature in features)
  328. focal_point = Rect(left, top, right, bottom)
  329. else:
  330. return None
  331. # Add 20% to width and height and give it a minimum size
  332. x, y = focal_point.centroid
  333. width, height = focal_point.size
  334. width *= 1.20
  335. height *= 1.20
  336. width = max(width, 100)
  337. height = max(height, 100)
  338. return Rect.from_point(x, y, width, height)
  339. @classmethod
  340. def get_rendition_model(cls):
  341. """Get the Rendition model for this Image model"""
  342. return cls.renditions.rel.related_model
  343. def _get_prefetched_renditions(self) -> Union[Iterable["AbstractRendition"], None]:
  344. if "renditions" in getattr(self, "_prefetched_objects_cache", {}):
  345. return self.renditions.all()
  346. return getattr(self, "prefetched_renditions", None)
  347. def _add_to_prefetched_renditions(self, rendition: "AbstractRendition") -> None:
  348. # Reuse this rendition if requested again from this object
  349. try:
  350. self._prefetched_objects_cache["renditions"]._result_cache.append(rendition)
  351. except (AttributeError, KeyError):
  352. pass
  353. try:
  354. self.prefetched_renditions.append(rendition)
  355. except AttributeError:
  356. pass
  357. def get_rendition(self, filter: Union["Filter", str]) -> "AbstractRendition":
  358. """
  359. Returns a ``Rendition`` instance with a ``file`` field value (an
  360. image) reflecting the supplied ``filter`` value and focal point values
  361. from this object.
  362. Note: If using custom image models, an instance of the custom rendition
  363. model will be returned.
  364. """
  365. Rendition = self.get_rendition_model()
  366. if isinstance(filter, str):
  367. filter = Filter(spec=filter)
  368. try:
  369. rendition = self.find_existing_rendition(filter)
  370. except Rendition.DoesNotExist:
  371. rendition = self.create_rendition(filter)
  372. # Reuse this rendition if requested again from this object
  373. self._add_to_prefetched_renditions(rendition)
  374. cache_key = Rendition.construct_cache_key(
  375. self, filter.get_cache_key(self), filter.spec
  376. )
  377. Rendition.cache_backend.set(cache_key, rendition)
  378. return rendition
  379. def find_existing_rendition(self, filter: "Filter") -> "AbstractRendition":
  380. """
  381. Returns an existing ``Rendition`` instance with a ``file`` field value
  382. (an image) reflecting the supplied ``filter`` value and focal point
  383. values from this object.
  384. If no such rendition exists, a ``DoesNotExist`` error is raised for the
  385. relevant model.
  386. Note: If using custom image models, an instance of the custom rendition
  387. model will be returned.
  388. """
  389. Rendition = self.get_rendition_model()
  390. try:
  391. return self.find_existing_renditions(filter)[filter]
  392. except KeyError:
  393. raise Rendition.DoesNotExist
  394. def create_rendition(self, filter: "Filter") -> "AbstractRendition":
  395. """
  396. Creates and returns a ``Rendition`` instance with a ``file`` field
  397. value (an image) reflecting the supplied ``filter`` value and focal
  398. point values from this object.
  399. This method is usually called by ``Image.get_rendition()``, after first
  400. checking that a suitable rendition does not already exist.
  401. Note: If using custom image models, an instance of the custom rendition
  402. model will be returned.
  403. """
  404. # Because of unique constraints applied to the model, we use
  405. # get_or_create() to guard against race conditions
  406. rendition, created = self.renditions.get_or_create(
  407. filter_spec=filter.spec,
  408. focal_point_key=filter.get_cache_key(self),
  409. defaults={"file": self.generate_rendition_file(filter)},
  410. )
  411. return rendition
  412. def get_renditions(self, *filter_specs: str) -> Dict[str, "AbstractRendition"]:
  413. """
  414. Returns a ``dict`` of ``Rendition`` instances with image files reflecting
  415. the supplied ``filter_specs``, keyed by the relevant ``filter_spec`` string.
  416. Note: If using custom image models, instances of the custom rendition
  417. model will be returned.
  418. """
  419. Rendition = self.get_rendition_model()
  420. filters = [Filter(spec) for spec in dict.fromkeys(filter_specs).keys()]
  421. # Find existing renditions where possible
  422. renditions = self.find_existing_renditions(*filters)
  423. # Create any renditions not found in prefetched values, cache or database
  424. not_found = [f for f in filters if f not in renditions]
  425. for filter, rendition in self.create_renditions(*not_found).items():
  426. self._add_to_prefetched_renditions(rendition)
  427. renditions[filter] = rendition
  428. # Update the cache
  429. cache_additions = {
  430. Rendition.construct_cache_key(
  431. self, filter.get_cache_key(self), filter.spec
  432. ): rendition
  433. for filter, rendition in renditions.items()
  434. # prevent writing of cached data back to the cache
  435. if not getattr(rendition, "_from_cache", False)
  436. }
  437. if cache_additions:
  438. Rendition.cache_backend.set_many(cache_additions)
  439. # Return a dict in the expected format
  440. return {filter.spec: rendition for filter, rendition in renditions.items()}
  441. def find_existing_renditions(
  442. self, *filters: "Filter"
  443. ) -> Dict["Filter", "AbstractRendition"]:
  444. """
  445. Returns a dictionary of existing ``Rendition`` instances with ``file``
  446. values (images) reflecting the supplied ``filters`` and the focal point
  447. values from this object.
  448. Filters for which an existing rendition cannot be found are ommitted
  449. from the return value. If none of the requested renditions have been
  450. created before, the return value will be an empty dict.
  451. """
  452. Rendition = self.get_rendition_model()
  453. filters_by_spec: Dict[str, Filter] = {f.spec: f for f in filters}
  454. found: Dict[Filter, AbstractRendition] = {}
  455. # Interrogate prefetched values first (where available)
  456. prefetched_renditions = self._get_prefetched_renditions()
  457. if prefetched_renditions is not None:
  458. # NOTE: When renditions are prefetched, it's assumed that if the
  459. # requested renditions exist, they will be present in the
  460. # prefetched value, and further cache/database lookups are avoided.
  461. # group renditions by the filters of interest
  462. potential_matches: Dict[Filter, List[AbstractRendition]] = defaultdict(list)
  463. for rendition in prefetched_renditions:
  464. try:
  465. filter = filters_by_spec[rendition.filter_spec]
  466. except KeyError:
  467. continue # this rendition can be ignored
  468. else:
  469. potential_matches[filter].append(rendition)
  470. # For each filter we have renditions for, look for one with a
  471. # 'focal_point_key' value matching filter.get_cache_key()
  472. for filter, renditions in potential_matches.items():
  473. focal_point_key = filter.get_cache_key(self)
  474. for rendition in renditions:
  475. if rendition.focal_point_key == focal_point_key:
  476. # to prevent writing of cached data back to the cache
  477. rendition._from_cache = True
  478. # use this rendition
  479. found[filter] = rendition
  480. # skip to the next filter
  481. break
  482. else:
  483. # Renditions are not prefetched, so attempt to find suitable
  484. # items in the cache or database
  485. # Query the cache first
  486. cache_keys = [
  487. Rendition.construct_cache_key(self, filter.get_cache_key(self), spec)
  488. for spec, filter in filters_by_spec.items()
  489. ]
  490. for rendition in Rendition.cache_backend.get_many(cache_keys).values():
  491. filter = filters_by_spec[rendition.filter_spec]
  492. found[filter] = rendition
  493. # For items not found in the cache, look in the database
  494. not_found = [f for f in filters if f not in found]
  495. if not_found:
  496. lookup_q = Q()
  497. for filter in not_found:
  498. lookup_q |= Q(
  499. filter_spec=filter.spec,
  500. focal_point_key=filter.get_cache_key(self),
  501. )
  502. for rendition in self.renditions.filter(lookup_q):
  503. filter = filters_by_spec[rendition.filter_spec]
  504. found[filter] = rendition
  505. return found
  506. def create_renditions(
  507. self, *filters: "Filter"
  508. ) -> Dict["Filter", "AbstractRendition"]:
  509. """
  510. Creates multiple ``Rendition`` instances with image files reflecting the supplied
  511. ``filters``, and returns them as a ``dict`` keyed by the relevant ``Filter`` instance.
  512. Where suitable renditions already exist in the database, they will be returned instead,
  513. so as not to create duplicates.
  514. This method is usually called by ``Image.get_renditions()``, after first
  515. checking that a suitable rendition does not already exist.
  516. Note: If using custom image models, an instance of the custom rendition
  517. model will be returned.
  518. """
  519. Rendition = self.get_rendition_model()
  520. if not filters:
  521. return {}
  522. if len(filters) == 1:
  523. # create_rendition() is better for single renditions, as it can
  524. # utilize QuerySet.get_or_create(), which has better handling of
  525. # race conditions
  526. filter = filters[0]
  527. return {filter: self.create_rendition(filter)}
  528. return_value: Dict[Filter, AbstractRendition] = {}
  529. filter_map: Dict[str, Filter] = {f.spec: f for f in filters}
  530. with self.open_file() as file:
  531. original_image_bytes = file.read()
  532. to_create = []
  533. def _generate_single_rendition(filter):
  534. # Using ContentFile here ensures we generate all renditions. Simply
  535. # passing self.file required several page reloads to generate all
  536. image_file = self.generate_rendition_file(
  537. filter, source=ContentFile(original_image_bytes, name=self.file.name)
  538. )
  539. to_create.append(
  540. Rendition(
  541. image=self,
  542. filter_spec=filter.spec,
  543. focal_point_key=filter.get_cache_key(self),
  544. file=image_file,
  545. )
  546. )
  547. with ThreadPoolExecutor() as executor:
  548. executor.map(_generate_single_rendition, filters)
  549. # Rendition generation can take a while. So, if other processes have created
  550. # identical renditions in the meantime, we should find them to avoid clashes.
  551. # NB: Clashes can still occur, because there is no get_or_create() equivalent
  552. # for multiple objects. However, this will reduce that risk considerably.
  553. files_for_deletion: List[File] = []
  554. # Assemble Q() to identify potential clashes
  555. lookup_q = Q()
  556. for rendition in to_create:
  557. lookup_q |= Q(
  558. filter_spec=rendition.filter_spec,
  559. focal_point_key=rendition.focal_point_key,
  560. )
  561. for existing in self.renditions.filter(lookup_q):
  562. # Include the existing rendition in the return value
  563. filter = filter_map[existing.filter_spec]
  564. return_value[filter] = existing
  565. for new in to_create:
  566. if (
  567. new.filter_spec == existing.filter_spec
  568. and new.focal_point_key == existing.focal_point_key
  569. ):
  570. # Avoid creating the new version
  571. to_create.remove(new)
  572. # Mark for deletion later, so as not to hold up creation
  573. files_for_deletion.append(new.file)
  574. for new in Rendition.objects.bulk_create(to_create, ignore_conflicts=True):
  575. filter = filter_map[new.filter_spec]
  576. return_value[filter] = new
  577. # Delete redundant rendition image files
  578. for file in files_for_deletion:
  579. file.delete(save=False)
  580. return return_value
  581. def generate_rendition_file(self, filter: "Filter", *, source: File = None) -> File:
  582. """
  583. Generates an in-memory image matching the supplied ``filter`` value
  584. and focal point value from this object, wraps it in a ``File`` object
  585. with a suitable filename, and returns it. The return value is used
  586. as the ``file`` field value for rendition objects saved by
  587. ``AbstractImage.create_rendition()``.
  588. If the contents of ``self.file`` has already been read into memory, the
  589. ``source`` keyword can be used to provide a reference to the in-memory
  590. ``File``, bypassing the need to reload the image contents from storage.
  591. NOTE: The responsibility of generating the new image from the original
  592. falls to the supplied ``filter`` object. If you want to do anything
  593. custom with rendition images (for example, to preserve metadata from
  594. the original image), you might want to consider swapping out ``filter``
  595. for an instance of a custom ``Filter`` subclass of your design.
  596. """
  597. cache_key = filter.get_cache_key(self)
  598. logger.debug(
  599. "Generating '%s' rendition for image %d",
  600. filter.spec,
  601. self.pk,
  602. )
  603. start_time = time.time()
  604. try:
  605. generated_image = filter.run(
  606. self,
  607. SpooledTemporaryFile(max_size=settings.FILE_UPLOAD_MAX_MEMORY_SIZE),
  608. source=source,
  609. )
  610. logger.debug(
  611. "Generated '%s' rendition for image %d in %.1fms",
  612. filter.spec,
  613. self.pk,
  614. (time.time() - start_time) * 1000,
  615. )
  616. except: # noqa:B901,E722
  617. logger.debug(
  618. "Failed to generate '%s' rendition for image %d",
  619. filter.spec,
  620. self.pk,
  621. )
  622. raise
  623. # Generate filename
  624. input_filename = os.path.basename(self.file.name)
  625. input_filename_without_extension, input_extension = os.path.splitext(
  626. input_filename
  627. )
  628. output_extension = (
  629. filter.spec.replace("|", ".")
  630. + IMAGE_FORMAT_EXTENSIONS[generated_image.format_name]
  631. )
  632. if cache_key:
  633. output_extension = cache_key + "." + output_extension
  634. # Truncate filename to prevent it going over 60 chars
  635. output_filename_without_extension = input_filename_without_extension[
  636. : (59 - len(output_extension))
  637. ]
  638. output_filename = output_filename_without_extension + "." + output_extension
  639. return File(generated_image.f, name=output_filename)
  640. def is_portrait(self):
  641. return self.width < self.height
  642. def is_landscape(self):
  643. return self.height < self.width
  644. def is_svg(self):
  645. _, ext = os.path.splitext(self.file.name)
  646. return ext.lower() == ".svg"
  647. @property
  648. def filename(self):
  649. return os.path.basename(self.file.name)
  650. @property
  651. def default_alt_text(self):
  652. # by default the alt text field (used in rich text insertion) is populated
  653. # from the title. Subclasses might provide a separate alt field, and
  654. # override this
  655. return self.title
  656. def is_editable_by_user(self, user):
  657. from wagtail.images.permissions import permission_policy
  658. return permission_policy.user_has_permission_for_instance(user, "change", self)
  659. class Meta:
  660. abstract = True
  661. class Image(AbstractImage):
  662. admin_form_fields = (
  663. "title",
  664. "file",
  665. "collection",
  666. "tags",
  667. "focal_point_x",
  668. "focal_point_y",
  669. "focal_point_width",
  670. "focal_point_height",
  671. )
  672. class Meta(AbstractImage.Meta):
  673. verbose_name = _("image")
  674. verbose_name_plural = _("images")
  675. permissions = [
  676. ("choose_image", "Can choose image"),
  677. ]
  678. class Filter:
  679. """
  680. Represents one or more operations that can be applied to an Image to produce a rendition
  681. appropriate for final display on the website. Usually this would be a resize operation,
  682. but could potentially involve colour processing, etc.
  683. """
  684. def __init__(self, spec=None):
  685. # The spec pattern is operation1-var1-var2|operation2-var1
  686. self.spec = spec
  687. @cached_property
  688. def operations(self):
  689. # Search for operations
  690. registered_operations = {}
  691. for fn in hooks.get_hooks("register_image_operations"):
  692. registered_operations.update(dict(fn()))
  693. # Build list of operation objects
  694. operations = []
  695. for op_spec in self.spec.split("|"):
  696. op_spec_parts = op_spec.split("-")
  697. if op_spec_parts[0] not in registered_operations:
  698. raise InvalidFilterSpecError(
  699. "Unrecognised operation: %s" % op_spec_parts[0]
  700. )
  701. op_class = registered_operations[op_spec_parts[0]]
  702. operations.append(op_class(*op_spec_parts))
  703. return operations
  704. @property
  705. def transform_operations(self):
  706. return [
  707. operation
  708. for operation in self.operations
  709. if isinstance(operation, TransformOperation)
  710. ]
  711. @property
  712. def filter_operations(self):
  713. return [
  714. operation
  715. for operation in self.operations
  716. if isinstance(operation, FilterOperation)
  717. ]
  718. def get_transform(self, image, size=None):
  719. """
  720. Returns an ImageTransform with all the transforms in this filter applied.
  721. The ImageTransform is an object with two attributes:
  722. - .size - The size of the final image
  723. - .matrix - An affine transformation matrix that combines any
  724. transform/scale/rotation operations that need to be applied to the image
  725. """
  726. if not size:
  727. size = (image.width, image.height)
  728. transform = ImageTransform(size, image_is_svg=image.is_svg())
  729. for operation in self.transform_operations:
  730. transform = operation.run(transform, image)
  731. return transform
  732. @contextmanager
  733. def get_willow_image(self, image: AbstractImage, source: File = None):
  734. if source is not None:
  735. yield willow.Image.open(source)
  736. else:
  737. with image.get_willow_image() as willow_image:
  738. yield willow_image
  739. def run(self, image: AbstractImage, output: BytesIO, source: File = None):
  740. with self.get_willow_image(image, source) as willow:
  741. original_format = willow.format_name
  742. # Fix orientation of image
  743. willow = willow.auto_orient()
  744. # Transform the image
  745. transform = self.get_transform(
  746. image, (willow.image.width, willow.image.height)
  747. )
  748. willow = willow.crop(transform.get_rect().round())
  749. willow = willow.resize(transform.size)
  750. # Apply filters
  751. env = {
  752. "original-format": original_format,
  753. }
  754. for operation in self.filter_operations:
  755. willow = operation.run(willow, image, env) or willow
  756. # Find the output format to use
  757. if "output-format" in env:
  758. # Developer specified an output format
  759. output_format = env["output-format"]
  760. else:
  761. # Convert bmp and webp to png by default
  762. default_conversions = {
  763. "avif": "png",
  764. "bmp": "png",
  765. "webp": "png",
  766. }
  767. # Convert unanimated GIFs to PNG as well
  768. if not willow.has_animation():
  769. default_conversions["gif"] = "png"
  770. # Allow the user to override the conversions
  771. conversion = getattr(settings, "WAGTAILIMAGES_FORMAT_CONVERSIONS", {})
  772. default_conversions.update(conversion)
  773. # Get the converted output format falling back to the original
  774. output_format = default_conversions.get(
  775. original_format, original_format
  776. )
  777. if output_format == "jpeg":
  778. # Allow changing of JPEG compression quality
  779. if "jpeg-quality" in env:
  780. quality = env["jpeg-quality"]
  781. else:
  782. quality = getattr(settings, "WAGTAILIMAGES_JPEG_QUALITY", 85)
  783. # If the image has an alpha channel, give it a white background
  784. if willow.has_alpha():
  785. willow = willow.set_background_color_rgb((255, 255, 255))
  786. return willow.save_as_jpeg(
  787. output, quality=quality, progressive=True, optimize=True
  788. )
  789. elif output_format == "png":
  790. return willow.save_as_png(output, optimize=True)
  791. elif output_format == "gif":
  792. return willow.save_as_gif(output)
  793. elif output_format == "webp":
  794. # Allow changing of WebP compression quality
  795. if (
  796. "output-format-options" in env
  797. and "lossless" in env["output-format-options"]
  798. ):
  799. return willow.save_as_webp(output, lossless=True)
  800. elif "webp-quality" in env:
  801. quality = env["webp-quality"]
  802. else:
  803. quality = getattr(settings, "WAGTAILIMAGES_WEBP_QUALITY", 85)
  804. return willow.save_as_webp(output, quality=quality)
  805. elif output_format == "avif":
  806. # Allow changing of AVIF compression quality
  807. if (
  808. "output-format-options" in env
  809. and "lossless" in env["output-format-options"]
  810. ):
  811. return willow.save_as_avif(output, lossless=True)
  812. elif "avif-quality" in env:
  813. quality = env["avif-quality"]
  814. else:
  815. quality = getattr(settings, "WAGTAILIMAGES_AVIF_QUALITY", 80)
  816. return willow.save_as_avif(output, quality=quality)
  817. elif output_format == "svg":
  818. return willow.save_as_svg(output)
  819. raise UnknownOutputImageFormatError(
  820. f"Unknown output image format '{output_format}'"
  821. )
  822. def get_cache_key(self, image):
  823. vary_parts = []
  824. for operation in self.operations:
  825. for field in getattr(operation, "vary_fields", []):
  826. value = getattr(image, field, "")
  827. vary_parts.append(str(value))
  828. vary_string = "-".join(vary_parts)
  829. # Return blank string if there are no vary fields
  830. if not vary_string:
  831. return ""
  832. return hashlib.sha1(vary_string.encode("utf-8")).hexdigest()[:8]
  833. class AbstractRendition(ImageFileMixin, models.Model):
  834. filter_spec = models.CharField(max_length=255, db_index=True)
  835. """ Use local ImageField with Willow support. """
  836. file = WagtailImageField(
  837. upload_to=get_rendition_upload_to,
  838. storage=get_rendition_storage,
  839. width_field="width",
  840. height_field="height",
  841. )
  842. width = models.IntegerField(editable=False)
  843. height = models.IntegerField(editable=False)
  844. focal_point_key = models.CharField(
  845. max_length=16, blank=True, default="", editable=False
  846. )
  847. wagtail_reference_index_ignore = True
  848. @property
  849. def url(self):
  850. return self.file.url
  851. @property
  852. def alt(self):
  853. return self.image.default_alt_text
  854. @property
  855. def attrs(self):
  856. """
  857. The src, width, height, and alt attributes for an <img> tag, as a HTML
  858. string
  859. """
  860. return flatatt(self.attrs_dict)
  861. @property
  862. def attrs_dict(self):
  863. """
  864. A dict of the src, width, height, and alt attributes for an <img> tag.
  865. """
  866. return OrderedDict(
  867. [
  868. ("src", self.url),
  869. ("width", self.width),
  870. ("height", self.height),
  871. ("alt", self.alt),
  872. ]
  873. )
  874. @property
  875. def full_url(self):
  876. url = self.url
  877. if hasattr(settings, "WAGTAILADMIN_BASE_URL") and url.startswith("/"):
  878. url = settings.WAGTAILADMIN_BASE_URL + url
  879. return url
  880. @property
  881. def filter(self):
  882. return Filter(self.filter_spec)
  883. @cached_property
  884. def focal_point(self):
  885. image_focal_point = self.image.get_focal_point()
  886. if image_focal_point:
  887. transform = self.filter.get_transform(self.image)
  888. return image_focal_point.transform(transform)
  889. @property
  890. def background_position_style(self):
  891. """
  892. Returns a `background-position` rule to be put in the inline style of an element which uses the rendition for its background.
  893. This positions the rendition according to the value of the focal point. This is helpful for when the element does not have
  894. the same aspect ratio as the rendition.
  895. For example:
  896. {% image page.image fill-1920x600 as image %}
  897. <div style="background-image: url('{{ image.url }}'); {{ image.background_position_style }}">
  898. </div>
  899. """
  900. focal_point = self.focal_point
  901. if focal_point:
  902. horz = int((focal_point.x * 100) // self.width)
  903. vert = int((focal_point.y * 100) // self.height)
  904. return f"background-position: {horz}% {vert}%;"
  905. else:
  906. return "background-position: 50% 50%;"
  907. def img_tag(self, extra_attributes={}):
  908. attrs = self.attrs_dict.copy()
  909. attrs.update(apps.get_app_config("wagtailimages").default_attrs)
  910. attrs.update(extra_attributes)
  911. return mark_safe(f"<img{flatatt(attrs)}>")
  912. def __html__(self):
  913. return self.img_tag()
  914. def get_upload_to(self, filename):
  915. folder_name = "images"
  916. filename = self.file.field.storage.get_valid_name(filename)
  917. return os.path.join(folder_name, filename)
  918. @classmethod
  919. def check(cls, **kwargs):
  920. errors = super().check(**kwargs)
  921. if not cls._meta.abstract:
  922. if not any(
  923. set(constraint) == {"image", "filter_spec", "focal_point_key"}
  924. for constraint in cls._meta.unique_together
  925. ):
  926. errors.append(
  927. checks.Error(
  928. "Custom rendition model %r has an invalid unique_together setting"
  929. % cls,
  930. hint="Custom rendition models must include the constraint "
  931. "('image', 'filter_spec', 'focal_point_key') in their unique_together definition.",
  932. obj=cls,
  933. id="wagtailimages.E001",
  934. )
  935. )
  936. return errors
  937. @staticmethod
  938. def construct_cache_key(image, filter_cache_key, filter_spec):
  939. return "wagtail-rendition-" + "-".join(
  940. [str(image.id), image.file_hash, filter_cache_key, filter_spec]
  941. )
  942. @classproperty
  943. def cache_backend(cls) -> BaseCache:
  944. try:
  945. return caches["renditions"]
  946. except InvalidCacheBackendError:
  947. return caches[DEFAULT_CACHE_ALIAS]
  948. def get_cache_key(self):
  949. return self.construct_cache_key(
  950. self.image, self.focal_point_key, self.filter_spec
  951. )
  952. def purge_from_cache(self):
  953. self.cache_backend.delete(self.get_cache_key())
  954. class Meta:
  955. abstract = True
  956. class Rendition(AbstractRendition):
  957. image = models.ForeignKey(
  958. Image, related_name="renditions", on_delete=models.CASCADE
  959. )
  960. class Meta:
  961. unique_together = (("image", "filter_spec", "focal_point_key"),)
  962. class UploadedImage(models.Model):
  963. """
  964. Temporary storage for images uploaded through the multiple image uploader, when validation rules (e.g.
  965. required metadata fields) prevent creating an Image object from the image file alone. In this case,
  966. the image file is stored against this model, to be turned into an Image object once the full form
  967. has been filled in.
  968. """
  969. file = models.ImageField(upload_to="uploaded_images", max_length=200)
  970. uploaded_by_user = models.ForeignKey(
  971. settings.AUTH_USER_MODEL,
  972. verbose_name=_("uploaded by user"),
  973. null=True,
  974. blank=True,
  975. editable=False,
  976. on_delete=models.SET_NULL,
  977. )
  978. uploaded_by_user.wagtail_reference_index_ignore = True