From 32ae1603c80b5d9d807bd1e53c690029116171e3 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Tue, 28 Jul 2026 18:31:21 +0200
Subject: [PATCH v7 1/5] Cherry-pick ImageBinaryField and related from
 pgeu-system

LLM-assisted grab from pgeu-system @ 704778e6b08e61fe.
---
 pgweb/util/fields.py                          | 90 +++++++++++++++++++
 pgweb/util/forms.py                           | 31 +++++++
 pgweb/util/helpers.py                         |  2 +-
 pgweb/util/image.py                           | 62 +++++++++++++
 .../util/widgets/inline_image_upload.html     |  9 ++
 pgweb/util/widgets.py                         | 17 ++++
 requirements.txt                              |  1 +
 7 files changed, 211 insertions(+), 1 deletion(-)
 create mode 100644 pgweb/util/fields.py
 create mode 100644 pgweb/util/forms.py
 create mode 100644 pgweb/util/image.py
 create mode 100644 pgweb/util/templates/util/widgets/inline_image_upload.html

diff --git a/pgweb/util/fields.py b/pgweb/util/fields.py
new file mode 100644
index 00000000..baf4bed6
--- /dev/null
+++ b/pgweb/util/fields.py
@@ -0,0 +1,90 @@
+from django.db import models
+from django.core.exceptions import ValidationError
+
+from .forms import ImageBinaryFormField
+
+import io
+
+from PIL import ImageFile
+
+from pgweb.util.image import rescale_image, apply_exif_orientation, EXIF_ORIENTATION_TAG
+
+
+class ImageBinaryField(models.Field):
+    empty_values = [None, b'']
+
+    def __init__(self, max_length, *args, **kwargs):
+        self.resolution = kwargs.pop('resolution', None)
+        self.auto_scale = kwargs.pop('auto_scale', False)
+        super(ImageBinaryField, self).__init__(*args, **kwargs)
+        self.max_length = max_length
+
+    def deconstruct(self):
+        name, path, args, kwargs = super(ImageBinaryField, self).deconstruct()
+        return name, path, args, kwargs
+
+    def get_internal_type(self):
+        return "ImageBinaryField"
+
+    def get_placeholder(self, value, compiler, connection):
+        return '%s'
+
+    def get_default(self):
+        return b''
+
+    def db_type(self, connection):
+        return 'bytea'
+
+    def get_db_prep_value(self, value, connection, prepared=False):
+        value = super(ImageBinaryField, self).get_db_prep_value(value, connection, prepared)
+        if value is not None:
+            return connection.Database.Binary(value)
+        return value
+
+    def to_python(self, value):
+        if self.max_length is not None and len(value) > self.max_length:
+            raise ValidationError("Maximum size of file is {} bytes".format(self.max_length))
+
+        if isinstance(value, memoryview):
+            v = bytes(value)
+        else:
+            v = value
+        try:
+            p = ImageFile.Parser()
+            p.feed(v)
+            p.close()
+            img = p.image
+        except Exception as e:
+            raise ValidationError("Could not parse image: %s" % e)
+
+        if img.format.upper() not in ('JPEG', 'PNG'):
+            raise ValidationError("Only JPEG or PNG files are allowed")
+
+        # Bake EXIF orientation in; re-encode only when actually rotated so
+        # untouched JPEGs are not needlessly recompressed.
+        if img.getexif().get(EXIF_ORIENTATION_TAG, 1) != 1:
+            fmt = img.format
+            img = apply_exif_orientation(img)
+            saver = io.BytesIO()
+            img.save(saver, format=fmt)
+            value = saver.getvalue()
+
+        if self.resolution:
+            if img.size[0] != self.resolution[0] or img.size[1] != self.resolution[1]:
+                if self.auto_scale:
+                    value = rescale_image(img, self.resolution, centered=True)
+                else:
+                    raise ValidationError("Image size must be {}x{}".format(*self.resolution))
+
+        return value
+
+    def save_form_data(self, instance, data):
+        if data is not None:
+            if not data:
+                data = b''
+            setattr(instance, self.name, data)
+
+    def formfield(self, **kwargs):
+        defaults = {'form_class': ImageBinaryFormField}
+        defaults.update(kwargs)
+        return super(ImageBinaryField, self).formfield(**defaults)
diff --git a/pgweb/util/forms.py b/pgweb/util/forms.py
new file mode 100644
index 00000000..2d885809
--- /dev/null
+++ b/pgweb/util/forms.py
@@ -0,0 +1,31 @@
+from django import forms
+from django.forms.widgets import FILE_INPUT_CONTRADICTION
+
+from .widgets import InlineImageUploadWidget
+
+
+class ImageBinaryFormField(forms.Field):
+    widget = InlineImageUploadWidget
+
+    def to_python(self, value):
+        if value is False:
+            # Value gets set to False if the clear checkbox is marked
+            return None
+        if value == FILE_INPUT_CONTRADICTION:
+            # This gets set if the user *both* uploads a new file *and* marks the clear checkbox
+            return None
+        if value is None:
+            return None
+        return value.read()
+
+    def prepare_value(self, value):
+        return value
+
+    def clean(self, data, initial=None):
+        if data is False:
+            if not self.required:
+                return False
+            data = None
+        if not data and initial:
+            return initial
+        return super(ImageBinaryFormField, self).clean(data)
diff --git a/pgweb/util/helpers.py b/pgweb/util/helpers.py
index 7e233caa..c41b7cc9 100644
--- a/pgweb/util/helpers.py
+++ b/pgweb/util/helpers.py
@@ -54,7 +54,7 @@ def simple_form(instancetype, itemid, request, formclass, formtemplate='base/for
             return HttpResponseRedirect(redirect)
 
         # Process this form
-        form = formclass(data=request.POST, instance=instance)
+        form = formclass(data=request.POST, files=request.FILES, instance=instance)
         if hasattr(form, 'filter_by_user'):
             form.filter_by_user(request.user)
         for fn in form.fields:
diff --git a/pgweb/util/image.py b/pgweb/util/image.py
new file mode 100644
index 00000000..a096b4e9
--- /dev/null
+++ b/pgweb/util/image.py
@@ -0,0 +1,62 @@
+import io
+
+from PIL import Image, ImageFile, ImageOps
+
+
+# EXIF "Orientation" tag: 274 decimal = 0x0112 hex as per Exif 2.32.
+# Values: 1 = normal, 2..8 = mirror/rotate transforms. Exposed here so we can
+# peek at the tag without re-encoding the image.
+EXIF_ORIENTATION_TAG = 0x0112
+
+
+# Bake EXIF orientation into pixel data; PIL does not auto-rotate on open.
+def apply_exif_orientation(img):
+    return ImageOps.exif_transpose(img)
+
+
+# Rescale an image in the form of bytes to a new set of bytes
+# in the same format. Assumes the aspect is correct and that
+# the incoming data is valid (it's expected to be for example
+# the output of previous image operations)
+def rescale_image_bytes(origbytes, resolution):
+    p = ImageFile.Parser()
+    p.feed(origbytes)
+    p.close()
+    img = p.image
+
+    return rescale_image(img, resolution)
+
+
+def rescale_image(img, resolution, centered=False):
+    fmt = img.format  # transpose returns a new image with .format = None
+    img = apply_exif_orientation(img)
+    scale = min(
+        float(resolution[0]) / float(img.size[0]),
+        float(resolution[1]) / float(img.size[1]),
+    )
+
+    newimg = img.resize(
+        (int(img.size[0] * scale), int(img.size[1] * scale)),
+        Image.BICUBIC,
+    )
+    saver = io.BytesIO()
+    if centered and newimg.size[0] != newimg.size[1]:
+        # This is not a square, so we have to roll it again
+        centeredimg = Image.new('RGBA', resolution)
+        centeredimg.paste(newimg, (
+            (resolution[0] - newimg.size[0]) // 2,
+            (resolution[1] - newimg.size[1]) // 2,
+        ))
+        centeredimg.save(saver, format='PNG')
+    else:
+        newimg.save(saver, format=fmt)
+
+    return saver.getvalue()
+
+
+def get_image_contenttype_from_bytes(image):
+    if bytearray(image[:3]) == b'\xFF\xD8\xFF':
+        return 'image/jpeg'
+    elif bytearray(image[:8]) == b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a':
+        return 'image/png'
+    raise Exception("Could not determine image format")
diff --git a/pgweb/util/templates/util/widgets/inline_image_upload.html b/pgweb/util/templates/util/widgets/inline_image_upload.html
new file mode 100644
index 00000000..0c265861
--- /dev/null
+++ b/pgweb/util/templates/util/widgets/inline_image_upload.html
@@ -0,0 +1,9 @@
+{%if widget.value %}
+<img src="data:{{widget.imagetype}};base64,{{widget.value}}" class="inline-photo-widget"><br/>
+{%endif%}
+{% if not widget.required %}
+<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}" />
+<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
+{% endif %}
+<input type="{{ widget.type }}" accept="image/jpeg,image/png" name="{{ widget.name }}"{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
+ />
diff --git a/pgweb/util/widgets.py b/pgweb/util/widgets.py
index 070ba536..9f9c048b 100644
--- a/pgweb/util/widgets.py
+++ b/pgweb/util/widgets.py
@@ -1,4 +1,10 @@
+from django import forms
 from django.forms.widgets import Widget
+from django.core.files.uploadedfile import UploadedFile
+from django.utils.safestring import mark_safe
+from django.template import loader
+
+import base64
 
 
 class TemplateRenderWidget(Widget):
@@ -10,3 +16,14 @@ class TemplateRenderWidget(Widget):
 
     def get_context(self, name, value, attrs):
         return self.templatecontext
+
+
+class InlineImageUploadWidget(forms.ClearableFileInput):
+    clear_checkbox_label = "Remove image"
+
+    def render(self, name, value, attrs=None, renderer=None):
+        context = self.get_context(name, value, attrs)
+        if value and not isinstance(value, UploadedFile):
+            context['widget']['value'] = base64.b64encode(value).decode('ascii')
+            context['widget']['imagetype'] = 'image/png' if bytes(value[:8]) == b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A' else 'image/jpg'
+        return mark_safe(loader.render_to_string('util/widgets/inline_image_upload.html', context))
diff --git a/requirements.txt b/requirements.txt
index bbc24831..70c8209c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
 Django>=5.2,<5.3
 psycopg2==2.8.5
 pycryptodomex>=3.4.7,<3.5
+Pillow>=10.0
 Markdown==3.0.1
 requests-oauthlib==1.0.0
 cvss==2.2
-- 
2.53.0

