Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions image_cropping/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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**, with a **live
WYSIWYG preview** of the cropped result. Precise, keyboard-accessible
numeric X/Y/W/H inputs live in a collapsed "Adjust crop precisely"
disclosure so they stay out of the common drag-to-crop flow. 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.
39 changes: 39 additions & 0 deletions image_cropping/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions image_cropping/admin.py
Original file line number Diff line number Diff line change
@@ -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)
194 changes: 194 additions & 0 deletions image_cropping/fields.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions image_cropping/static/image_cropping/cropper.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions image_cropping/static/image_cropping/cropper.min.js

Large diffs are not rendered by default.

Loading
Loading