From d34377ec2a70580abcd6bf17946c0cbc76537534 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 15 Jun 2026 12:23:04 -0700 Subject: [PATCH 1/4] Replace django-image-cropping with in-repo Cropper.js widget (#1299, #1269) Swap the EOL PyPI django-image-cropping (v1.7, Feb 2022; Jcrop+jQuery; Django <=4.0; "save first, then crop") for an in-repo fork whose only material change is a modern Cropper.js admin widget that previews and crops client-side, before the first save. Closes the long-standing #1299 friction and retires a Django-6.1-blocking dependency (#1269 sibling). New top-level image_cropping/ package (project source, like sortedm2m_filter_horizontal_widget): ImageRatioField, crop_corners, ImageCroppingMixin, CropImageWidget, vendored cropper.min.{js,css} v1.6.2 + ml_cropper.{js,css} glue (instant FileReader preview -> Cropper.js -> "x1,y1,x2,y2" box; keyboard-accessible numeric X/Y/W/H inputs; no-JS fallback). Deliberately migration-neutral: ImageRatioField stays a CharField whose deconstruct() returns "image_cropping.fields.ImageRatioField", so the gitignored per-environment migrations that import image_cropping.fields keep working and the DB column is untouched (pinned by a regression test). Keeping the package name is what preserves those imports. - requirements.txt: drop django-image-cropping (django-appconf falls away as a transitive dep); INSTALLED_APPS keeps 'image_cropping' (now the fork). - Banner: convert the 2 cropped_thumbnail calls to the standard {% thumbnail box= %} idiom; remove 19 now-dead {% load cropping %} lines; add {% load thumbnail %} to base.html. - Refresh the obsolete "Save and continue editing" help texts on Banner, News, Person, Photo, Project. - Update CLAUDE.md image-handling docs; add image_cropping/README.md. Tests: 14 new (crop_corners parsing, deconstruct migration contract, widget Media uses Cropper not Jcrop, model crop metadata); full suite 149 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +- image_cropping/README.md | 59 ++++ image_cropping/__init__.py | 39 +++ image_cropping/admin.py | 19 ++ image_cropping/fields.py | 194 ++++++++++++++ .../static/image_cropping/cropper.min.css | 9 + .../static/image_cropping/cropper.min.js | 10 + .../static/image_cropping/ml_cropper.css | 70 +++++ .../static/image_cropping/ml_cropper.js | 252 ++++++++++++++++++ image_cropping/thumbnail_processors.py | 50 ++++ image_cropping/widgets.py | 39 +++ makeabilitylab/settings.py | 15 +- requirements.txt | 12 +- website/models/banner.py | 2 +- website/models/news.py | 3 +- website/models/person.py | 2 +- website/models/photo.py | 2 +- website/models/project.py | 4 +- .../display_news_item_sidebar_snippet.html | 1 - .../snippets/display_people_snippet.html | 1 - .../snippets/display_person_snippet.html | 1 - .../display_project_gallery_lightbox.html | 1 - .../display_project_lead_snippet.html | 1 - .../snippets/display_project_snippet.html | 1 - .../snippets/display_pub_snippet.html | 1 - .../display_short_carousel_snippet.html | 3 +- .../snippets/display_talk_snippet.html | 1 - .../snippets/display_video_snippet.html | 1 - website/templates/website/base.html | 4 +- website/templates/website/index.html | 1 - website/templates/website/member.html | 1 - website/templates/website/news_item.html | 1 - website/templates/website/news_listing.html | 1 - website/templates/website/people.html | 1 - website/templates/website/project.html | 1 - .../templates/website/project_listing.html | 1 - .../website/view_project_people.html | 1 - website/tests/test_image_cropping.py | 208 +++++++++++++++ 38 files changed, 977 insertions(+), 40 deletions(-) create mode 100644 image_cropping/README.md create mode 100644 image_cropping/__init__.py create mode 100644 image_cropping/admin.py create mode 100644 image_cropping/fields.py create mode 100644 image_cropping/static/image_cropping/cropper.min.css create mode 100644 image_cropping/static/image_cropping/cropper.min.js create mode 100644 image_cropping/static/image_cropping/ml_cropper.css create mode 100644 image_cropping/static/image_cropping/ml_cropper.js create mode 100644 image_cropping/thumbnail_processors.py create mode 100644 image_cropping/widgets.py create mode 100644 website/tests/test_image_cropping.py diff --git a/CLAUDE.md b/CLAUDE.md index 4d68a492..9eb83656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,9 @@ Every container start runs, in order: `collectstatic` → `makemigrations` → ` ### Image handling -`image_cropping` + `easy_thumbnails` work together: cropping defines the crop box, easy_thumbnails generates sized variants. `THUMBNAIL_PROCESSORS` is configured so crop_corners runs before the default chain. Image processing requires ImageMagick (installed in the Dockerfile) and a custom `imagemagick-policy.xml` is mounted into `/etc/ImageMagick-6/policy.xml` to enable PDF processing (see issue #974). +`image_cropping` + `easy_thumbnails` work together: cropping defines the crop box (stored as an `"x1,y1,x2,y2"` string by `ImageRatioField`), easy_thumbnails generates sized variants. `THUMBNAIL_PROCESSORS` is configured so `crop_corners` runs before the default chain, applying the stored box to any `{% thumbnail … box=obj.cropping %}` render. Image processing requires ImageMagick (installed in the Dockerfile) and a custom `imagemagick-policy.xml` is mounted into `/etc/ImageMagick-6/policy.xml` to enable PDF processing (see issue #974). + +**`image_cropping` is an in-repo fork**, not the PyPI `django-image-cropping` (which was EOL Jcrop+jQuery, Django ≤4.0). Like `sortedm2m_filter_horizontal_widget`, the top-level `image_cropping/` package is project source code and shadows/replaces the dropped dependency. Its admin widget is **Cropper.js** (vendored static, no build step): editors preview and crop client-side *before* the first save (#1299/#1269). The data layer is intentionally unchanged — `ImageRatioField` is still a `CharField` whose `deconstruct()` returns `image_cropping.fields.ImageRatioField`, so the gitignored per-environment migrations that `import image_cropping.fields` keep working and the DB column is untouched (a regression test pins this path). See `image_cropping/README.md`. To bump Cropper.js, replace the vendored `static/image_cropping/cropper.min.{js,css}` (stay on the v1.x API; v2 is a different API). ### Rich text diff --git a/image_cropping/README.md b/image_cropping/README.md new file mode 100644 index 00000000..972e1169 --- /dev/null +++ b/image_cropping/README.md @@ -0,0 +1,59 @@ +# image_cropping (in-repo fork) + +A small local fork of [django-image-cropping](https://github.com/jonasundderwolf/django-image-cropping), +treated as project source code (like `sortedm2m_filter_horizontal_widget`). +**It is in `INSTALLED_APPS` and shadows the PyPI package, which is no longer a +dependency.** + +## Why we forked + +Upstream `django-image-cropping` v1.7 (Feb 2022) is unmaintained for our needs: +it bundles **Jcrop + jQuery**, its classifiers stop at **Django 4.0**, and its +workflow is "upload the image, **Save and continue editing**, scroll back up, +*then* crop" — because Jcrop crops against the already-saved file on the server. +See issue #1299 (instant preview/crop) and #1269 (de-risking dependencies ahead +of Django 6.1 LTS). + +## What we changed + +- **New admin widget on [Cropper.js](https://github.com/fengyuanchen/cropperjs) + v1.6.2** (MIT, vendored as static files, no build step). It previews and + crops the image **client-side, before the first save**. See + `widgets.py` + `static/image_cropping/ml_cropper.js`. +- **Removed** the pluggable backend / `django-appconf` config layer; easy_thumbnails + is wired directly. +- Dropped upstream's unused pieces for our codebase (ForeignKey-image cropping, + the `cropped_thumbnail` template tag — Banner now uses the same + `{% thumbnail … box=banner.cropping %}` idiom as everywhere else). + +## What we deliberately kept identical + +- `ImageRatioField` is still a `CharField` storing `"x1,y1,x2,y2"`, and its + `deconstruct()` still returns `image_cropping.fields.ImageRatioField`. + **This is a migration-safety contract** (pinned by + `website/tests/test_image_cropping.py`): `website/migrations/` is gitignored + and regenerated per environment, existing migration files + `import image_cropping.fields`, and deploys are push-only with no server shell + to repair a broken `migrate`. Keeping the package name and field path is what + makes the swap a no-op at the database layer. +- `crop_corners` (the easy_thumbnails processor) is unchanged, so every + `{% thumbnail … box=obj.cropping %}` call site keeps working. + +## Layout + +``` +image_cropping/ + __init__.py exports ImageRatioField, ImageCroppingMixin + fields.py ImageRatioField (+ max_cropping helper) + thumbnail_processors.py crop_corners (easy_thumbnails processor) + admin.py ImageCroppingMixin + widgets.py CropImageWidget (Cropper.js) + static/image_cropping/ cropper.min.{js,css} (vendored) + ml_cropper.{js,css} +``` + +## Upgrading Cropper.js + +Replace `static/image_cropping/cropper.min.{js,css}` with a newer 1.x build and +re-run `collectstatic`. The glue in `ml_cropper.js` targets the Cropper.js v1 +API (`getData`/`setData` in natural pixels); v2 is a web-components rewrite with +a different API and would require rewriting the glue. diff --git a/image_cropping/__init__.py b/image_cropping/__init__.py new file mode 100644 index 00000000..958c29cb --- /dev/null +++ b/image_cropping/__init__.py @@ -0,0 +1,39 @@ +""" +image_cropping - in-repo fork of django-image-cropping (#1299 / #1269). + +Why this package exists +----------------------- +We replaced the PyPI ``django-image-cropping`` (v1.7, Feb 2022; Jcrop + +jQuery; classifiers stop at Django 4.0; "save first, *then* crop") with this +small local fork. It is treated as project source code, exactly like +``sortedm2m_filter_horizontal_widget``. + +What changed vs. upstream +------------------------- +- The admin widget is rewritten on **Cropper.js** (vendored, v1.6.2, MIT, no + build step). It previews and crops the image *client-side, before the first + save* -- closing the long-standing "upload, save, scroll up, crop" friction + (#1299). +- The pluggable backend / ``django-appconf`` config layer is removed; we wire + easy_thumbnails directly. + +What deliberately did NOT change +-------------------------------- +- ``ImageRatioField`` is still a ``CharField`` storing an ``"x1,y1,x2,y2"`` + box, and its ``deconstruct()`` still returns + ``image_cropping.fields.ImageRatioField``. That keeps the DB column and every + gitignored, per-environment migration that ``import image_cropping.fields`` + working -- critical because deploys are push-only with no server shell to + repair a broken ``migrate`` (see CLAUDE.md). +- ``crop_corners`` still feeds the stored box to easy_thumbnails, so every + ``{% thumbnail ... box=obj.cropping %}`` call site is untouched. + +Keeping the package name ``image_cropping`` (rather than renaming) is +intentional: it is what preserves those migration imports. +""" + +from .admin import ImageCroppingMixin +from .fields import ImageRatioField + +__all__ = ["ImageCroppingMixin", "ImageRatioField"] +__version__ = "2.0.0-makelab" diff --git a/image_cropping/admin.py b/image_cropping/admin.py new file mode 100644 index 00000000..dcd42ae3 --- /dev/null +++ b/image_cropping/admin.py @@ -0,0 +1,19 @@ +""" +Admin mixin that swaps the crop widget onto cropped ImageFields. + +Add :class:`ImageCroppingMixin` to a ``ModelAdmin`` whose model has one or more +:class:`~image_cropping.fields.ImageRatioField`. For each ImageField referenced +by a ratio field, the file input is rendered with :class:`CropImageWidget` +(Cropper.js); the ratio field renders as a plain text input carrying the crop +box. ``ml_cropper.js`` wires the two together in the browser. +""" + +from .widgets import CropImageWidget + + +class ImageCroppingMixin: + def formfield_for_dbfield(self, db_field, request, **kwargs): + crop_fields = getattr(self.model, "crop_fields", {}) + if db_field.name in crop_fields: + kwargs["widget"] = CropImageWidget + return super().formfield_for_dbfield(db_field, request, **kwargs) diff --git a/image_cropping/fields.py b/image_cropping/fields.py new file mode 100644 index 00000000..630191d1 --- /dev/null +++ b/image_cropping/fields.py @@ -0,0 +1,194 @@ +""" +ImageRatioField - stores a crop box for an associated ImageField. + +A thin ``CharField`` that holds an ``"x1,y1,x2,y2"`` rectangle (original-image +pixel coordinates). It does not store an image itself; it records *how* a +sibling ``ImageField`` should be cropped, and easy_thumbnails renders the crop +on demand via :func:`image_cropping.thumbnail_processors.crop_corners`. + +Ported from upstream django-image-cropping with the appconf/backend layer +removed. The constructor signature, ``deconstruct()`` output, and the +``crop_fields`` / ``ratio_fields`` model metadata are kept identical to +upstream so existing (gitignored, per-environment) migrations and the admin +mixin keep working unchanged. See the package docstring for the full rationale. +""" + +from django import forms +from django.db import models +from django.db.models import signals + + +def max_cropping(width, height, image_width, image_height, free_crop=False): + """ + Return the largest centered box of aspect ratio ``width/height`` that fits + inside an ``image_width`` x ``image_height`` image, as ``[x1, y1, x2, y2]``. + + Used to seed a sensible default crop when none has been set yet. + """ + if free_crop: + return [0, 0, image_width, image_height] + + ratio = width / float(height) + if image_width < image_height * ratio: + # width fits fully, height needs to be cropped + offset = int(round((image_height - (image_width / ratio)) / 2)) + return [0, offset, image_width, image_height - offset] + + # height fits fully, width needs to be cropped + offset = int(round((image_width - (image_height * ratio)) / 2)) + return [offset, 0, image_width - offset, image_height] + + +def _image_size(image): + """Return (width, height) for an image file, honoring EXIF orientation.""" + try: + return image.width, image.height + except AttributeError: + # Fall back to opening the file (e.g. freshly uploaded, not yet saved). + from easy_thumbnails.source_generators import pil_image + return pil_image(image).size + + +class ImageRatioField(models.CharField): + """ + Store the crop boundaries for ``image_field`` at a fixed aspect ratio. + + Args: + image_field: name of the sibling ``ImageField`` to crop. (Upstream also + supported ``"fk_field__image"`` for cropping an image on a related + object; that path is unused here but the kwarg is still accepted.) + size: ``"WIDTHxHEIGHT"`` -- defines both the aspect ratio and the + minimum acceptable crop size. + free_crop: if True, allow any aspect ratio. + size_warning: if True, warn in the admin when the chosen crop is + smaller than ``size``. + + ``adapt_rotation``, ``allow_fullsize``, and ``hide_image_field`` are + accepted for migration/back-compat but are not otherwise used. + """ + + def __init__( + self, + image_field, + size="0x0", + free_crop=False, + adapt_rotation=False, + allow_fullsize=False, + verbose_name=None, + help_text=None, + hide_image_field=False, + size_warning=False, + ): + if "__" in image_field: + self.image_field, self.image_fk_field = image_field.split("__") + else: + self.image_field, self.image_fk_field = image_field, None + self.width, self.height = list(map(int, size.split("x"))) + self.free_crop = free_crop + self.adapt_rotation = adapt_rotation + self.allow_fullsize = allow_fullsize + self.size_warning = size_warning + self.hide_image_field = hide_image_field + super().__init__( + max_length=255, + default="", + blank=True, + verbose_name=verbose_name, + help_text=help_text, + ) + + def deconstruct(self): + """ + Return migration-serialization data. + + IMPORTANT: the returned path is hard-pinned to + ``"image_cropping.fields.ImageRatioField"``. Existing migration files + (gitignored, regenerated per environment) reference exactly this path; + changing it would break ``migrate`` on the next push-to-deploy with no + way to fix it server-side. Pinned by a regression test. + """ + if self.image_fk_field: + image_field = "%s__%s" % (self.image_field, self.image_fk_field) + else: + image_field = self.image_field + + args = (image_field, "%dx%d" % (self.width, self.height)) + kwargs = { + "free_crop": self.free_crop, + "adapt_rotation": self.adapt_rotation, + "allow_fullsize": self.allow_fullsize, + "verbose_name": self.verbose_name, + "help_text": self.help_text, + "hide_image_field": self.hide_image_field, + "size_warning": self.size_warning, + } + return self.name, "image_cropping.fields.ImageRatioField", args, kwargs + + def contribute_to_class(self, cls, name, **kwargs): + super().contribute_to_class(cls, name, **kwargs) + if not cls._meta.abstract: + # Record which ImageFields are cropped (so the admin mixin can swap + # in the crop widget) and which ratio fields exist on the model. + if not hasattr(cls, "crop_fields"): + cls.add_to_class("crop_fields", {}) + cls.crop_fields[self.image_field] = { + "fk_field": self.image_fk_field, + "hidden": self.hide_image_field, + } + + if not hasattr(cls, "ratio_fields"): + cls.add_to_class("ratio_fields", []) + cls.ratio_fields.append(name) + + signals.pre_save.connect(self.initial_cropping, sender=cls) + + def initial_cropping(self, sender, instance, *args, **kwargs): + """ + Seed an empty ratio field with a centered max-area box on save, so a + sensible crop exists even if the editor never touched the widget (or + had JavaScript disabled). + """ + for ratiofieldname in getattr(instance, "ratio_fields", []): + if getattr(instance, ratiofieldname): + continue # cropping already set + + ratiofield = instance._meta.get_field(ratiofieldname) + image = getattr(instance, ratiofield.image_field) + if ratiofield.image_fk_field and image: # image is on a ForeignKey + image = getattr(image, ratiofield.image_fk_field) + if not image: + continue + + try: + width, height = _image_size(image) + box = max_cropping( + ratiofield.width, + ratiofield.height, + width, + height, + free_crop=ratiofield.free_crop, + ) + box = ",".join(map(str, box)) + except (IOError, OSError): + box = "" + setattr(instance, ratiofieldname, box) + + def formfield(self, **kwargs): + """ + Render as a text input carrying the data-* attributes that + ``ml_cropper.js`` reads to drive Cropper.js. The input itself holds the + ``"x1,y1,x2,y2"`` value the JS writes back. + """ + ratio = 0 if self.free_crop else self.width / float(self.height) + kwargs["widget"] = forms.TextInput( + attrs={ + "class": "image-ratio", + "data-image-field": self.image_field, + "data-my-name": self.name, + "data-ratio": str(ratio), + "data-min-width": self.width, + "data-min-height": self.height, + "data-size-warning": str(self.size_warning).lower(), + } + ) + return super().formfield(**kwargs) diff --git a/image_cropping/static/image_cropping/cropper.min.css b/image_cropping/static/image_cropping/cropper.min.css new file mode 100644 index 00000000..8e34d751 --- /dev/null +++ b/image_cropping/static/image_cropping/cropper.min.css @@ -0,0 +1,9 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:02.731Z + */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/image_cropping/static/image_cropping/cropper.min.js b/image_cropping/static/image_cropping/cropper.min.js new file mode 100644 index 00000000..3102cb54 --- /dev/null +++ b/image_cropping/static/image_cropping/cropper.min.js @@ -0,0 +1,10 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:05.335Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/image_cropping/static/image_cropping/ml_cropper.css b/image_cropping/static/image_cropping/ml_cropper.css new file mode 100644 index 00000000..0d446fd3 --- /dev/null +++ b/image_cropping/static/image_cropping/ml_cropper.css @@ -0,0 +1,70 @@ +/* + * ml_cropper.css — styling for the admin Cropper.js widget (#1299). + * Cropper.js needs its target to be block-level inside a sized box. + */ + +.ml-cropper { + margin: 8px 0 16px 0; + max-width: 640px; +} + +/* The raw "x1,y1,x2,y2" text field is kept in the DOM as the value holder but + hidden from view; the cropper UI replaces it. */ +.ml-cropper__hidden-row { + display: none !important; +} + +.ml-cropper__stage { + max-width: 100%; + max-height: 420px; + background: #f3f3f3; + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; +} + +.ml-cropper__stage:not(.ml-cropper__stage--active) { + display: none; +} + +/* Cropper.js resizes/wraps this image; constrain it so a tall original does + not blow out the admin layout. */ +.ml-cropper__image { + display: block; + max-width: 100%; + max-height: 420px; +} + +.ml-cropper__warning { + margin: 6px 0 0 0; + color: #8a6d3b; + font-size: 0.85em; +} + +.ml-cropper__controls { + margin-top: 10px; + padding: 8px 10px; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + align-items: center; +} + +.ml-cropper__legend { + font-weight: 600; + font-size: 0.85em; + padding: 0 4px; +} + +.ml-cropper__num { + font-size: 0.85em; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ml-cropper__num-input { + width: 6em; +} diff --git a/image_cropping/static/image_cropping/ml_cropper.js b/image_cropping/static/image_cropping/ml_cropper.js new file mode 100644 index 00000000..03904fb6 --- /dev/null +++ b/image_cropping/static/image_cropping/ml_cropper.js @@ -0,0 +1,252 @@ +/** + * ml_cropper.js — client-side image cropping for the Django admin (#1299). + * + * Replaces the old Jcrop/jQuery widget from django-image-cropping. For each + * crop field it: + * 1. finds the sibling for the image being cropped; + * 2. shows an INSTANT preview the moment a file is selected (via + * URL.createObjectURL) — no "Save and continue editing" round-trip; + * 3. runs Cropper.js locked to the field's aspect ratio; + * 4. writes the selection back as an "x1,y1,x2,y2" box (original-image + * pixels) into the ratio field that the server persists. + * + * Coordinates are read from Cropper's getData(), which is already in natural + * image pixels, so they map directly onto the stored original and onto the + * easy_thumbnails `crop_corners` processor — no scaling math, no baked file. + * + * Accessibility: alongside the drag UI, each cropper exposes labeled numeric + * X / Y / Width / Height inputs that are fully keyboard-operable and kept in + * sync with the visual crop box. With JS off, the raw ratio field still + * submits and the server seeds a sensible centered crop. + * + * Vanilla JS only (no jQuery/build step), per project conventions. + */ +(function () { + "use strict"; + + if (typeof window.Cropper === "undefined") { + // Cropper.js failed to load; leave the raw ratio field in place so the + // form still works (server seeds a default crop on save). + return; + } + + /** Parse "x1,y1,x2,y2" -> {x, y, width, height} or null. */ + function parseBox(value) { + if (!value) return null; + var p = value.split(",").map(function (n) { return parseInt(n, 10); }); + if (p.length !== 4 || p.some(isNaN)) return null; + return { x: p[0], y: p[1], width: p[2] - p[0], height: p[3] - p[1] }; + } + + /** Format Cropper getData() -> "x1,y1,x2,y2" with clamped integers. */ + function formatBox(data, naturalWidth, naturalHeight) { + var x1 = Math.max(0, Math.round(data.x)); + var y1 = Math.max(0, Math.round(data.y)); + var x2 = Math.min(naturalWidth, Math.round(data.x + data.width)); + var y2 = Math.min(naturalHeight, Math.round(data.y + data.height)); + return [x1, y1, x2, y2].join(","); + } + + /** + * Locate the file input paired with a ratio field. Both share the form + * prefix (e.g. inline "banner_set-0-"), differing only in the trailing + * field name: "...-cropping" -> "...-image". + */ + function findImageInput(ratioInput, myName, imageFieldName) { + var name = ratioInput.getAttribute("name") || ""; + if (name.slice(-myName.length) !== myName) return null; + var imageName = name.slice(0, name.length - myName.length) + imageFieldName; + var form = ratioInput.closest("form") || document; + return form.querySelector('[name="' + imageName + '"]'); + } + + function makeNumberInput(label, idBase, key) { + var wrap = document.createElement("label"); + wrap.className = "ml-cropper__num"; + wrap.textContent = label + " "; + var input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.step = "1"; + input.id = idBase + "_" + key; + input.className = "ml-cropper__num-input"; + input.setAttribute("data-key", key); + wrap.appendChild(input); + return { wrap: wrap, input: input }; + } + + function setupField(ratioInput) { + if (ratioInput.dataset.mlCropperReady === "1") return; + ratioInput.dataset.mlCropperReady = "1"; + + var imageFieldName = ratioInput.getAttribute("data-image-field"); + var myName = ratioInput.getAttribute("data-my-name") || ""; + var ratioAttr = parseFloat(ratioInput.getAttribute("data-ratio")); + var aspectRatio = ratioAttr > 0 ? ratioAttr : NaN; // NaN => free crop + var minWidth = parseInt(ratioInput.getAttribute("data-min-width"), 10) || 0; + var minHeight = parseInt(ratioInput.getAttribute("data-min-height"), 10) || 0; + var sizeWarning = ratioInput.getAttribute("data-size-warning") === "true"; + + var fileInput = findImageInput(ratioInput, myName, imageFieldName); + if (!fileInput) return; + + // Hide the raw "x1,y1,x2,y2" text box (kept in the DOM as the value holder) + // and its label row; the cropper UI replaces it visually. + var ratioRow = ratioInput.closest(".form-row, .field-" + myName) || ratioInput.parentNode; + if (ratioRow) ratioRow.classList.add("ml-cropper__hidden-row"); + + // Build the cropper UI under the file input's row. + var idBase = "ml_cropper_" + (ratioInput.id || myName); + var container = document.createElement("div"); + container.className = "ml-cropper"; + + var stage = document.createElement("div"); + stage.className = "ml-cropper__stage"; + var img = document.createElement("img"); + img.className = "ml-cropper__image"; + img.alt = "Crop preview"; + stage.appendChild(img); + container.appendChild(stage); + + var warning = document.createElement("p"); + warning.className = "ml-cropper__warning"; + warning.setAttribute("role", "alert"); + warning.hidden = true; + container.appendChild(warning); + + // Keyboard-accessible numeric controls. + var controls = document.createElement("fieldset"); + controls.className = "ml-cropper__controls"; + var legend = document.createElement("legend"); + legend.textContent = "Crop region (pixels)"; + legend.className = "ml-cropper__legend"; + controls.appendChild(legend); + var nums = { + x: makeNumberInput("X", idBase, "x"), + y: makeNumberInput("Y", idBase, "y"), + width: makeNumberInput("Width", idBase, "width"), + height: makeNumberInput("Height", idBase, "height"), + }; + Object.keys(nums).forEach(function (k) { controls.appendChild(nums[k].wrap); }); + container.appendChild(controls); + + var fileRow = fileInput.closest(".form-row, .field-" + imageFieldName) || fileInput.parentNode; + fileRow.parentNode.insertBefore(container, fileRow.nextSibling); + + var cropper = null; + var syncing = false; // guard against crop<->numeric feedback loops + var pendingBox = parseBox(ratioInput.value); // existing crop to restore + + function updateWarning(data) { + if (!sizeWarning) return; + var tooSmall = data.width < minWidth || data.height < minHeight; + if (tooSmall) { + warning.hidden = false; + warning.textContent = + "Heads up: this crop (" + Math.round(data.width) + "×" + + Math.round(data.height) + " px) is smaller than the recommended " + + minWidth + "×" + minHeight + " px and may look soft when enlarged."; + } else { + warning.hidden = true; + } + } + + function onCrop() { + if (!cropper || syncing) return; + var data = cropper.getData(); + var img2 = cropper.getImageData(); + ratioInput.value = formatBox(data, img2.naturalWidth, img2.naturalHeight); + syncing = true; + nums.x.input.value = Math.round(data.x); + nums.y.input.value = Math.round(data.y); + nums.width.input.value = Math.round(data.width); + nums.height.input.value = Math.round(data.height); + syncing = false; + updateWarning(data); + } + + function applyNumbers() { + if (!cropper || syncing) return; + syncing = true; + cropper.setData({ + x: parseFloat(nums.x.input.value) || 0, + y: parseFloat(nums.y.input.value) || 0, + width: parseFloat(nums.width.input.value) || minWidth, + height: parseFloat(nums.height.input.value) || minHeight, + }); + syncing = false; + onCrop(); + } + Object.keys(nums).forEach(function (k) { + nums[k].input.addEventListener("change", applyNumbers); + }); + + function initCropper(src, restoreBox) { + if (cropper) { cropper.destroy(); cropper = null; } + pendingBox = restoreBox || null; + img.src = src; + stage.classList.add("ml-cropper__stage--active"); + } + + img.addEventListener("load", function () { + if (cropper) { cropper.destroy(); cropper = null; } + cropper = new window.Cropper(img, { + aspectRatio: aspectRatio, + viewMode: 1, + autoCropArea: 1, + responsive: true, + // Keep the on-screen pixels identical to what the server crops: + // easy_thumbnails / Pillow crop_corners operates on the raw stored + // image without applying EXIF orientation, so the cropper must show + // the same un-rotated pixels. (checkOrientation:true also stalls on + // blob: object URLs from a just-picked file, which is the instant + // preview path.) + checkOrientation: false, + background: true, + ready: function () { + if (pendingBox) { + syncing = true; + cropper.setData(pendingBox); + syncing = false; + pendingBox = null; + } + onCrop(); + }, + crop: onCrop, + }); + }); + + // INSTANT preview: crop straight from the just-picked file, pre-upload. + fileInput.addEventListener("change", function () { + var file = fileInput.files && fileInput.files[0]; + if (!file) return; + var url = URL.createObjectURL(file); + initCropper(url, null); + img.addEventListener("load", function revoke() { + URL.revokeObjectURL(url); + img.removeEventListener("load", revoke); + }); + }); + + // Existing image (edit page): load the full-res original for re-cropping. + var originalUrl = fileInput.getAttribute("data-original-url"); + if (originalUrl) { + initCropper(originalUrl, parseBox(ratioInput.value)); + } + } + + function scan(root) { + (root || document).querySelectorAll("input.image-ratio").forEach(setupField); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { scan(document); }); + } else { + scan(document); + } + + // Newly added inline rows (Django dispatches a native formset:added event). + document.addEventListener("formset:added", function (event) { + scan(event.target || document); + }); +})(); diff --git a/image_cropping/thumbnail_processors.py b/image_cropping/thumbnail_processors.py new file mode 100644 index 00000000..fad26f0c --- /dev/null +++ b/image_cropping/thumbnail_processors.py @@ -0,0 +1,50 @@ +""" +easy_thumbnails processor that applies a stored crop box. + +Registered ahead of the default easy_thumbnails processors in +``settings.THUMBNAIL_PROCESSORS`` so that any thumbnail rendered with +``box=obj.cropping`` is first cropped to the editor-chosen rectangle and then +resized. Ported verbatim from upstream django-image-cropping -- it is pure +Pillow and has no dependency on the (removed) backend/config layer. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def crop_corners(image, box=None, **kwargs): + """ + Crop ``image`` to the selection stored by an :class:`ImageRatioField`. + + ``box`` is either a string ``"x1,y1,x2,y2"`` or a four-item list/tuple of + integers, in original-image pixel coordinates. Anything unparseable (or a + negative first value, which signals "cropping disabled") leaves the image + untouched. + + Example: + >>> crop_corners(pil_image, box="10,10,210,210") # 200x200 crop + """ + if not box: + return image + + if not isinstance(box, (list, tuple)): + # convert cropping string to a list of integers if necessary + try: + box = list(map(int, box.split(","))) + except (ValueError, AttributeError): + # there's garbage in the cropping field, ignore + logger.warning('Unable to parse "box" parameter "%s". Ignoring.', box) + box = [] + + if len(box) == 4: + if box[0] < 0: + # a negative first box value indicates that cropping is disabled + return image + width = abs(box[2] - box[0]) + height = abs(box[3] - box[1]) + if width and height and (width, height) != image.size: + image = image.crop(box) + else: + logger.warning('"box" parameter requires four values. Ignoring "%r".', box) + return image diff --git a/image_cropping/widgets.py b/image_cropping/widgets.py new file mode 100644 index 00000000..b6947537 --- /dev/null +++ b/image_cropping/widgets.py @@ -0,0 +1,39 @@ +""" +CropImageWidget - admin file input wired for client-side cropping. + +Renders Django's standard admin file widget, but (a) advertises the current +image's URL via ``data-original-url`` so the JS can load it for re-cropping, and +(b) pulls in Cropper.js plus our glue code (``ml_cropper.js``) instead of the +retired Jcrop/jQuery bundle. All cropping happens in the browser; on submit +only the ``"x1,y1,x2,y2"`` box (in the sibling ratio field) is saved. +""" + +from django import forms +from django.contrib.admin.widgets import AdminFileWidget + + +class CropImageWidget(AdminFileWidget): + class Media: + css = { + "all": ( + "image_cropping/cropper.min.css", + "image_cropping/ml_cropper.css", + ) + } + js = ( + "image_cropping/cropper.min.js", + "image_cropping/ml_cropper.js", + ) + + def render(self, name, value, attrs=None, renderer=None): + attrs = attrs or {} + attrs["class"] = (attrs.get("class", "") + " crop-image-field").strip() + # Expose the existing image so the JS can initialize Cropper against the + # full-resolution original (keeps crop coordinates in natural pixels). + if value and hasattr(value, "url"): + try: + attrs["data-original-url"] = value.url + except ValueError: + # No file associated (e.g. cleared) -> nothing to preview. + pass + return super().render(name, value, attrs, renderer) diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index 41e0c6e4..becf033f 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -181,12 +181,15 @@ 'django.contrib.humanize', # for humanizing numbers in templates: https://docs.djangoproject.com/en/4.2/ref/contrib/humanize/ - # In Django, both easy-thumbnails and django-image-cropping serve different purposes - # and can be used together for different functionalities. So, while easy-thumbnails can handle - # resizing and scaling of images, if you need specific cropping functionality where users can - # select a part of the image to crop, you would use django-image-cropping in conjunction with - # easy-thumbnails. This combination provides a more comprehensive image handling solution - 'image_cropping', # for cropping uploaded images: https://github.com/jonasundderwolf/django-image-cropping + # Image handling = two cooperating pieces: easy-thumbnails resizes/scales, + # while image_cropping lets editors pick the crop box. easy-thumbnails then + # renders that box at any size on demand (see crop_corners in + # THUMBNAIL_PROCESSORS below). + # NOTE: 'image_cropping' is an IN-REPO fork (top-level image_cropping/), not + # the PyPI django-image-cropping package. It ships a modern Cropper.js admin + # widget (instant client-side preview/crop). See image_cropping/README.md + # and issues #1299 / #1269. Treated as project source, like sortedm2m below. + 'image_cropping', 'easy_thumbnails', # for dynamically creating thumbnails: https://github.com/SmileyChris/easy-thumbnails 'sortedm2m', # Used for SortedManyToManyFields in admin interface: https://pypi.org/project/django-sortedm2m-filter-horizontal-widget/ 'ckeditor', # Used for news page editing in admin interface: https://pypi.org/project/django-ckeditor/ diff --git a/requirements.txt b/requirements.txt index 927f65e4..25b3e67b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,12 +78,14 @@ pypdf==6.13.2 # See: https://pypi.org/project/Pillow/ Pillow==12.2.0 -# Django Image Cropping - for cropping images in admin -# Note: Last release Feb 2022, but should work with Django 5.2 -# See: https://pypi.org/project/django-image-cropping/ -django-image-cropping==1.7 +# Image cropping in the admin is provided by the in-repo `image_cropping/` +# package (a fork of django-image-cropping), NOT a PyPI dependency. We dropped +# django-image-cropping==1.7 (Feb 2022, EOL Jcrop+jQuery, Django <=4.0) in favor +# of a modern Cropper.js widget that previews/crops before saving. See #1299 / +# #1269 and image_cropping/README.md. (django-appconf was a transitive dep of +# that package and is no longer needed.) -# Easy Thumbnails - thumbnail generation, works with django-image-cropping +# Easy Thumbnails - thumbnail generation, works with the in-repo image_cropping # See: https://pypi.org/project/easy-thumbnails/ easy_thumbnails==2.10.1 diff --git a/website/models/banner.py b/website/models/banner.py index d0905dc2..ccda28d1 100644 --- a/website/models/banner.py +++ b/website/models/banner.py @@ -19,7 +19,7 @@ class Banner(models.Model): image = models.ImageField(blank=True, upload_to=UniquePathAndRename(UPLOAD_DIR, True), max_length=255) cropping = ImageRatioField('image', '1600x500', free_crop=False) - image.help_text = 'You must select "Save and continue editing" at the bottom of the page after uploading a new image for cropping.\ + image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.\ Please note that since we are using a responsive design with fixed height banners, your selected image may appear\ differently on various screens.' diff --git a/website/models/news.py b/website/models/news.py index c4e4d7f4..d4845f04 100644 --- a/website/models/news.py +++ b/website/models/news.py @@ -45,8 +45,7 @@ def get_thumbnail_size_as_str(): # Following the scheme of above thumbnails in other models image = models.ImageField(blank=True, upload_to=UniquePathAndRename("news", True), max_length=255) - image.help_text = 'You must select "Save and continue editing" at the bottom of the page after\ - uploading a new image for cropping. ' + image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.' # We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping # that simply stores the boundaries of a cropped image. You must pass it the corresponding ImageField diff --git a/website/models/person.py b/website/models/person.py index 4663a8d9..449310f6 100644 --- a/website/models/person.py +++ b/website/models/person.py @@ -136,7 +136,7 @@ def get_thumbnail_size_as_str(): # We use the get_unique_path function because otherwise if two people use the same # filename (something generic like picture.jpg), one will overwrite the other. image = models.ImageField(blank=True, upload_to=get_upload_to_for_person, max_length=255) - image.help_text = 'You must select "Save and continue editing" at the bottom of the page after uploading a new image for cropping.' + image.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.' # We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping # that simply stores the boundaries of a cropped image. You must pass it the corresponding ImageField diff --git a/website/models/photo.py b/website/models/photo.py index ec034485..d3455033 100644 --- a/website/models/photo.py +++ b/website/models/photo.py @@ -28,7 +28,7 @@ def get_cropping_size_as_str(): # Comment generated with help from chat.bing.com # See also: https://docs.djangoproject.com/en/4.2/ref/models/fields/#django.db.models.ForeignKey.on_delete project = models.ForeignKey(Project, blank=True, null=True, on_delete=models.SET_NULL) - picture.help_text = 'To crop this image, you must select "Save and continue editing" at the bottom of the page after uploading' + picture.help_text = 'After choosing an image, crop it right here using the cropper below — no need to save first.' # We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping # that simply stores the boundaries of a cropped image. You must pass it the corresponding ImageField diff --git a/website/models/project.py b/website/models/project.py index 99526e70..869bd577 100644 --- a/website/models/project.py +++ b/website/models/project.py @@ -84,8 +84,8 @@ def get_thumbnail_size_as_str(): # TODO: consider switching gallery_image var name to thumbnail gallery_image = models.ImageField(upload_to=IMAGE_DIR, blank=True, null=True, max_length=255) gallery_image.help_text = "This is the image which will show up on the project gallery page.\ - It is not displayed anywhere else. You must select 'Save and continue editing' at the\ - bottom of the page after uploading a new image for cropping." + It is not displayed anywhere else. After choosing an image, crop it right here\ + using the cropper below — no need to save first." thumbnail_alt_text = models.CharField(max_length=1024, blank=True, null=True) # We use the django-image-cropping ImageRatioField https://github.com/jonasundderwolf/django-image-cropping diff --git a/website/templates/snippets/display_news_item_sidebar_snippet.html b/website/templates/snippets/display_news_item_sidebar_snippet.html index 36cab2a7..abc20ab6 100644 --- a/website/templates/snippets/display_news_item_sidebar_snippet.html +++ b/website/templates/snippets/display_news_item_sidebar_snippet.html @@ -21,7 +21,6 @@ {% load static %} {% load thumbnail %} -{% load cropping %}
  • {% for lead in active_leads %} diff --git a/website/templates/snippets/display_project_snippet.html b/website/templates/snippets/display_project_snippet.html index c088eacf..e237073b 100644 --- a/website/templates/snippets/display_project_snippet.html +++ b/website/templates/snippets/display_project_snippet.html @@ -49,7 +49,6 @@ {% load static %} {% load thumbnail %} -{% load cropping %} {% load ml_tags %}