From 81f37d9ce5b4b73f6b2da71e8fee732f88aa29a7 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 8 Feb 2021 17:48:06 +0100 Subject: [PATCH] PDF layout: Allow to show photos from questions (#1919) --- doc/api/resources/orders.rst | 6 +- doc/development/api/general.rst | 2 +- src/pretix/api/auth/devicesecurity.py | 3 + src/pretix/api/serializers/order.py | 39 ++++++- src/pretix/api/views/order.py | 62 ++++++++++- src/pretix/base/invoice.py | 23 +--- src/pretix/base/pdf.py | 101 +++++++++++++++++- src/pretix/base/signals.py | 27 ++++- .../templates/pretixcontrol/pdf/index.html | 27 +++++ src/pretix/control/views/pdf.py | 6 +- src/pretix/helpers/reportlab.py | 22 ++++ .../static/pretixcontrol/js/ui/editor.js | 75 +++++++++++++ .../static/pretixcontrol/scss/pdfeditor.css | 4 + 13 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 src/pretix/helpers/reportlab.py diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 527d437a0..133aab76a 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -220,7 +220,7 @@ downloads list of objects List of ticket └ url string Download URL answers list of objects Answers to user-defined questions ├ question integer Internal ID of the answered question -├ answer string Text representation of the answer +├ answer string Text representation of the answer (URL if answer is a file) ├ question_identifier string The question's ``identifier`` field ├ options list of integers Internal IDs of selected option(s)s (only for choice types) └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s @@ -274,6 +274,10 @@ pdf_data object Data object req The ``checkin.type`` attribute has been added. +.. versionchanged:: 3.16 + + Answers to file questions are now returned as an URL. + .. _order-payment-resource: Order payment resource diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 7ce56c4fb..359bd2588 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -79,7 +79,7 @@ Ticket designs """""""""""""" .. automodule:: pretix.base.signals - :members: layout_text_variables + :members: layout_text_variables, layout_image_variables .. automodule:: pretix.plugins.ticketoutputpdf.signals :members: override_layout diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 08f3dfedc..715927c28 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -41,6 +41,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:checkinlistpos-redeem'), ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:order-list'), + ('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ) @@ -68,6 +69,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:checkinlist-status'), ('POST', 'api-v1:checkinlistpos-redeem'), ('GET', 'api-v1:revokedsecrets-list'), + ('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ) @@ -97,6 +99,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:order-list'), ('GET', 'api-v1:order-detail'), ('DELETE', 'api-v1:orderposition-detail'), + ('GET', 'api-v1:orderposition-pdf_image'), ('POST', 'api-v1:order-mark_canceled'), ('POST', 'api-v1:orderpayment-list'), ('POST', 'api-v1:orderrefund-list'), diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 2df995aaa..c3a501698 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -25,7 +25,7 @@ from pretix.base.models import ( from pretix.base.models.orders import ( CartPosition, OrderFee, OrderPayment, OrderRefund, RevokedTicketSecret, ) -from pretix.base.pdf import get_variables +from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages from pretix.base.services.locking import NoLockManager from pretix.base.services.pricing import get_price @@ -112,6 +112,17 @@ class AnswerSerializer(I18nAwareModelSerializer): question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) + def to_representation(self, instance): + r = super().to_representation(instance) + if r['answer'].startswith('file://') and instance.orderposition: + r['answer'] = reverse('api-v1:orderposition-answer', kwargs={ + 'organizer': instance.orderposition.order.event.organizer.slug, + 'event': instance.orderposition.order.event.slug, + 'pk': instance.orderposition.pk, + 'question': instance.question_id, + }, request=self.context['request']) + return r + class Meta: model = QuestionAnswer fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers') @@ -265,6 +276,9 @@ class PdfDataSerializer(serializers.Field): if 'vars' not in self.context: self.context['vars'] = get_variables(self.context['request'].event) + if 'vars_images' not in self.context: + self.context['vars_images'] = get_images(self.context['request'].event) + for k, f in self.context['vars'].items(): res[k] = f['evaluate'](instance, instance.order, ev) @@ -279,7 +293,28 @@ class PdfDataSerializer(serializers.Field): for k, v in instance.item._cached_meta_data.items(): res['itemmeta:' + k] = v - return res + res['images'] = {} + + for k, f in self.context['vars_images'].items(): + if 'etag' in f: + has_image = etag = f['etag'](instance, instance.order, ev) + else: + has_image = f['etag'](instance, instance.order, ev) + etag = None + if has_image: + url = reverse('api-v1:orderposition-pdf_image', kwargs={ + 'organizer': instance.order.event.organizer.slug, + 'event': instance.order.event.slug, + 'pk': instance.pk, + 'key': k, + }, request=self.context['request']) + if etag: + url += f'#etag={etag}' + res['images'][k] = url + else: + res['images'][k] = None + + return res class OrderPositionSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index c6101b750..80511ce31 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -1,4 +1,6 @@ import datetime +import mimetypes +import os from decimal import Decimal import django_filters @@ -12,6 +14,7 @@ from django.utils.timezone import make_aware, now from django.utils.translation import gettext as _ from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled +from PIL import Image from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import ( @@ -35,8 +38,9 @@ from pretix.base.models import ( Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent, TaxRule, TeamAPIToken, generate_secret, ) -from pretix.base.models.orders import RevokedTicketSecret +from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret from pretix.base.payment import PaymentException +from pretix.base.pdf import get_images from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( @@ -913,6 +917,62 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, vi 'tax_rule': tr.pk if tr else None, }) + @action(detail=True, url_name='answer', url_path=r'answer/(?P\d+)') + def answer(self, request, **kwargs): + pos = self.get_object() + answer = get_object_or_404( + QuestionAnswer, + orderposition=self.get_object(), + question_id=kwargs.get('question') + ) + if not answer.file: + raise NotFound() + + ftype, ignored = mimetypes.guess_type(answer.file.name) + resp = FileResponse(answer.file, content_type=ftype or 'application/binary') + resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + os.path.basename(answer.file.name).split('.', 1)[1] + ) + return resp + + @action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P[^/]+)') + def pdf_image(self, request, key, **kwargs): + pos = self.get_object() + + image_vars = get_images(request.event) + if key not in image_vars: + raise NotFound('Unknown key') + + image_file = image_vars[key]['evaluate'](pos, pos.order, pos.subevent or self.request.event) + if image_file is None: + raise NotFound('No image available') + + if getattr(image_file, 'name', ''): + ftype, ignored = mimetypes.guess_type(image_file.name) + extension = os.path.basename(image_file.name).split('.')[-1] + else: + img = Image.open(image_file) + ftype = Image.MIME[img.format] + extensions = { + 'GIF': 'gif', 'TIFF': 'tif', 'BMP': 'bmp', 'JPEG': 'jpg', 'PNG': 'png' + } + extension = extensions.get(img.format, 'bin') + if hasattr(image_file, 'seek'): + image_file.seek(0) + + resp = FileResponse(image_file, content_type=ftype or 'application/binary') + resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + key, + extension, + ) + return resp + @action(detail=True, url_name='download', url_path='download/(?P[^/]+)') def download(self, request, output, **kwargs): provider = self._get_output_provider(output) diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 8e0d6857f..2dc88e433 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -12,12 +12,10 @@ from django.utils.formats import date_format, localize from django.utils.translation import ( get_language, gettext, gettext_lazy, pgettext, ) -from PIL.Image import BICUBIC from reportlab.lib import pagesizes from reportlab.lib.enums import TA_LEFT, TA_RIGHT from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.units import mm -from reportlab.lib.utils import ImageReader from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfbase.ttfonts import TTFont @@ -31,6 +29,7 @@ from pretix.base.decimal import round_decimal from pretix.base.models import Event, Invoice, Order from pretix.base.signals import register_invoice_renderers from pretix.base.templatetags.money import money_filter +from pretix.helpers.reportlab import ThumbnailingImageReader logger = logging.getLogger(__name__) @@ -221,26 +220,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): return 'invoice.pdf', 'application/pdf', buffer.read() -class ThumbnailingImageReader(ImageReader): - def resize(self, width, height, dpi): - if width is None: - width = height * self._image.size[0] / self._image.size[1] - if height is None: - height = width * self._image.size[1] / self._image.size[0] - self._image.thumbnail( - size=(int(width * dpi / 72), int(height * dpi / 72)), - resample=BICUBIC - ) - self._data = None - return width, height - - def _jpeg_fh(self): - # Bypass a reportlab-internal optimization that falls back to the original - # file handle if the file is a JPEG, and therefore does not respect the - # (smaller) size of the modified image. - return None - - class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): identifier = 'classic' verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 2eaa45f37..1749d9f32 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -1,4 +1,5 @@ import copy +import hashlib import itertools import logging import os @@ -35,9 +36,9 @@ from reportlab.platypus import Paragraph from pretix.base.i18n import language from pretix.base.invoice import ThumbnailingImageReader -from pretix.base.models import Order, OrderPosition +from pretix.base.models import Order, OrderPosition, Question from pretix.base.settings import PERSON_NAME_SCHEMES -from pretix.base.signals import layout_text_variables +from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format from pretix.presale.style import get_fonts @@ -339,6 +340,47 @@ DEFAULT_VARIABLES = OrderedDict(( "evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "") }), )) +DEFAULT_IMAGES = OrderedDict([]) + + +@receiver(layout_image_variables, dispatch_uid="pretix_base_layout_image_variables_questions") +def images_from_questions(sender, *args, **kwargs): + def get_answer(op, order, event, question_id, etag): + a = None + if op.addon_to: + if 'answers' in getattr(op.addon_to, '_prefetched_objects_cache', {}): + try: + a = [a for a in op.addon_to.answers.all() if a.question_id == question_id][0] + except IndexError: + pass + else: + a = op.addon_to.answers.filter(question_id=question_id).first() + + if 'answers' in getattr(op, '_prefetched_objects_cache', {}): + try: + a = [a for a in op.answers.all() if a.question_id == question_id][0] + except IndexError: + pass + else: + a = op.answers.filter(question_id=question_id).first() + + if not a or not a.file or not any(a.file.name.lower().endswith(e) for e in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")): + return None + else: + if etag: + return hashlib.sha1(a.file.name.encode()).hexdigest() + return a.file + + d = {} + for q in sender.questions.all(): + if q.type != Question.TYPE_FILE: + continue + d['question_{}'.format(q.identifier)] = { + 'label': _('Question: {question}').format(question=q.question), + 'evaluate': partial(get_answer, question_id=q.pk, etag=False), + 'etag': partial(get_answer, question_id=q.pk, etag=True), + } + return d @receiver(layout_text_variables, dispatch_uid="pretix_base_layout_text_variables_questions") @@ -369,6 +411,8 @@ def variables_from_questions(sender, *args, **kwargs): d = {} for q in sender.questions.all(): + if q.type == Question.TYPE_FILE: + continue d['question_{}'.format(q.pk)] = { 'label': _('Question: {question}').format(question=q.question), 'editor_sample': _('').format(question=q.question), @@ -387,6 +431,15 @@ def _get_ia_name_part(key, op, order, ev): return order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else '' +def get_images(event): + v = copy.copy(DEFAULT_IMAGES) + + for recv, res in layout_image_variables.send(sender=event): + v.update(res) + + return v + + def get_variables(event): v = copy.copy(DEFAULT_VARIABLES) @@ -427,6 +480,7 @@ class Renderer: self.layout = layout self.background_file = background_file self.variables = get_variables(event) + self.images = get_images(event) self.event = event if self.background_file: self.bg_bytes = self.background_file.read() @@ -514,6 +568,47 @@ class Renderer: return '(error)' return '' + def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): + ev = self._get_ev(op, order) + if not o['content'] or o['content'] not in self.images: + image_file = None + else: + try: + image_file = self.images[o['content']]['evaluate'](op, order, ev) + except: + logger.exception('Failed to process variable.') + image_file = None + + if image_file: + ir = ThumbnailingImageReader(image_file) + try: + ir.resize(float(o['width']) * mm, float(o['height']) * mm, 300) + except: + logger.exception("Can not resize image") + pass + canvas.drawImage( + image=ir, + x=float(o['left']) * mm, + y=float(o['bottom']) * mm, + width=float(o['width']) * mm, + height=float(o['height']) * mm, + preserveAspectRatio=True, + anchor='c', # centered in frame + mask='auto' + ) + else: + canvas.saveState() + canvas.setFillColorRGB(.8, .8, .8, alpha=1) + canvas.rect( + x=float(o['left']) * mm, + y=float(o['bottom']) * mm, + width=float(o['width']) * mm, + height=float(o['height']) * mm, + stroke=0, + fill=1, + ) + canvas.restoreState() + def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): font = o['fontfamily'] if o['bold']: @@ -572,6 +667,8 @@ class Renderer: for o in self.layout: if o['type'] == "barcodearea": self._draw_barcodearea(canvas, op, o) + elif o['type'] == "imagearea": + self._draw_imagearea(canvas, op, order, o) elif o['type'] == "textarea": self._draw_textarea(canvas, op, order, o) elif o['type'] == "poweredby": diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 4a1b9fce9..f58a80f80 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -613,6 +613,7 @@ If the email is associated with a specific user, e.g. a notification email, the well, otherwise it will be ``None``. """ + layout_text_variables = EventPluginSignal() """ This signal is sent out to collect variables that can be used to display text in ticket-related PDF layouts. @@ -627,11 +628,35 @@ dictionaries as values that contain keys like in the following example:: } } -The evaluate member will be called with the order position, order and event as arguments. The event might +The ``evaluate`` member will be called with the order position, order and event as arguments. The event might also be a subevent, if applicable. """ +layout_image_variables = EventPluginSignal() +""" +This signal is sent out to collect variables that can be used to display dynamic images in ticket-related PDF layouts. +Receivers are expected to return a dictionary with globally unique identifiers as keys and more +dictionaries as values that contain keys like in the following example:: + + return { + "profile": { + "label": _("Profile picture"), + "evaluate": lambda orderposition, order, event: ContentFile(b"some-image-data"), + "etag": lambda orderposition, order, event: hash(b"some-image-data") + } + } + +The ``evaluate`` member will be called with the order position, order and event as arguments. The event might +also be a subevent, if applicable. The return value of ``evaluate`` should be an instance of Django's ``File`` +class and point to a valid JPEG or PNG file. If no image is available, ``evaluate`` should return ``None``. + +The ``etag`` member will be called with the same arguments as ``evaluate`` but should return a ``str`` value +uniquely identifying the version of the file. This can be a hash of the file, but can also be something else. +If no image is available, ``etag`` should return ``None``. In some cases, this can speed up the implementation. +""" + + timeline_events = EventPluginSignal() """ This signal is sent out to collect events for the time line shown on event dashboards. You are passed diff --git a/src/pretix/control/templates/pretixcontrol/pdf/index.html b/src/pretix/control/templates/pretixcontrol/pdf/index.html index 445f01302..95d20b6b2 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/index.html +++ b/src/pretix/control/templates/pretixcontrol/pdf/index.html @@ -228,6 +228,18 @@ id="toolbox-position-y"> +
+
+
+ +
+
+
+ +
+

@@ -332,6 +344,17 @@
+
+
+
+ +
+

@@ -382,6 +405,10 @@ {% trans "pretix Logo" %} +
diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index cc49a5be3..133657b84 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -22,7 +22,7 @@ from reportlab.lib.units import mm from pretix.base.i18n import language from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition -from pretix.base.pdf import get_variables +from pretix.base.pdf import get_images, get_variables from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.database import rolledback_transaction @@ -211,11 +211,15 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): def get_variables(self): return get_variables(self.request.event) + def get_images(self): + return get_images(self.request.event) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['fonts'] = get_fonts() ctx['pdf'] = self.get_current_background() ctx['variables'] = self.get_variables() + ctx['images'] = self.get_images() ctx['layout'] = json.dumps(self.get_current_layout()) ctx['title'] = self.title ctx['locales'] = [p for p in settings.LANGUAGES if p[0] in self.request.event.settings.locales] diff --git a/src/pretix/helpers/reportlab.py b/src/pretix/helpers/reportlab.py new file mode 100644 index 000000000..9a78f6e3f --- /dev/null +++ b/src/pretix/helpers/reportlab.py @@ -0,0 +1,22 @@ +from PIL.Image import BICUBIC +from reportlab.lib.utils import ImageReader + + +class ThumbnailingImageReader(ImageReader): + def resize(self, width, height, dpi): + if width is None: + width = height * self._image.size[0] / self._image.size[1] + if height is None: + height = width * self._image.size[1] / self._image.size[0] + self._image.thumbnail( + size=(int(width * dpi / 72), int(height * dpi / 72)), + resample=BICUBIC + ) + self._data = None + return width, height + + def _jpeg_fh(self): + # Bypass a reportlab-internal optimization that falls back to the original + # file handle if the file is a JPEG, and therefore does not respect the + # (smaller) size of the modified image. + return None diff --git a/src/pretix/static/pretixcontrol/js/ui/editor.js b/src/pretix/static/pretixcontrol/js/ui/editor.js index 63deaee6c..7c3cdd358 100644 --- a/src/pretix/static/pretixcontrol/js/ui/editor.js +++ b/src/pretix/static/pretixcontrol/js/ui/editor.js @@ -20,6 +20,32 @@ fabric.Poweredby = fabric.util.createClass(fabric.Image, { fabric.Poweredby.fromObject = function (object, callback, forceAsync) { return fabric.Object._fromObject('Poweredby', object, callback, forceAsync); }; +fabric.Imagearea = fabric.util.createClass(fabric.Rect, { + type: 'imagearea', + + initialize: function (text, options) { + options || (options = {}); + + this.callSuper('initialize', text, options); + this.set('label', options.label || ''); + }, + + toObject: function () { + return fabric.util.object.extend(this.callSuper('toObject'), {}); + }, + + _render: function (ctx) { + ctx.fillStyle = '#009' + this.callSuper('_render', ctx); + + ctx.font = '12px Helvetica'; + ctx.fillStyle = '#fff'; + ctx.fillText(this.content, -this.width / 2, -this.height / 2 + 20, this.width); + }, +}); +fabric.Imagearea.fromObject = function (object, callback, forceAsync) { + return fabric.Object._fromObject('Imagearea', object, callback, forceAsync); +}; fabric.Barcodearea = fabric.util.createClass(fabric.Rect, { type: 'barcodearea', @@ -139,6 +165,15 @@ var editor = { rotation: o.angle, align: o.textAlign, }); + } else if (o.type === "imagearea") { + d.push({ + type: "imagearea", + left: editor._px2mm(left).toFixed(2), + bottom: editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - top).toFixed(2), + height: editor._px2mm(o.height * o.scaleY).toFixed(2), + width: editor._px2mm(o.width * o.scaleX).toFixed(2), + content: o.content, + }); } else if (o.type === "barcodearea") { d.push({ type: "barcodearea", @@ -165,6 +200,13 @@ var editor = { o = editor._add_qrcode(); o.content = d.content; o.scaleToHeight(editor._mm2px(d.size)); + } else if (d.type === "imagearea") { + o = editor._add_imagearea(d.content); + o.content = d.content; + o.setHeight(editor._mm2px(d.height)); + o.setWidth(editor._mm2px(d.width)); + o.setScaleX(1); + o.setScaleY(1); } else if (d.type === "poweredby") { o = editor._add_poweredby(d.content); o.content = d.content; @@ -356,6 +398,10 @@ var editor = { if (o.type === "barcodearea") { $("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); + } else if (o.type === "imagearea") { + $("#toolbox-height").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); + $("#toolbox-width").val(editor._px2mm(o.width * o.scaleX).toFixed(2)); + $("#toolbox-imagecontent").val(o.content); } else if (o.type === "poweredby") { $("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); $("#toolbox-poweredby-style").val(o.content); @@ -411,6 +457,16 @@ var editor = { o.setScaleX(1); o.setScaleY(1); o.set('top', new_top) + } else if (o.type === "imagearea") { + var new_w = editor._mm2px($("#toolbox-width").val()); + var new_h = editor._mm2px($("#toolbox-height").val()); + new_top += o.height * o.scaleY - new_h; + o.setHeight(new_h); + o.setWidth(new_w); + o.setScaleX(1); + o.setScaleY(1); + o.set('top', new_top) + o.content = $("#toolbox-imagecontent").val(); } else if (o.type === "poweredby") { var new_h = Math.max(1, editor._mm2px($("#toolbox-squaresize").val())); new_top += o.height * o.scaleY - new_h; @@ -467,6 +523,8 @@ var editor = { $("#toolbox-heading").text(gettext("Text object")); } else if (o.type === "barcodearea") { $("#toolbox-heading").text(gettext("Barcode area")); + } else if (o.type === "imagearea") { + $("#toolbox-heading").text(gettext("Image area")); } else if (o.type === "poweredby") { $("#toolbox-heading").text(gettext("Powered by pretix")); } else { @@ -530,6 +588,22 @@ var editor = { return rect; }, + _add_imagearea: function () { + var rect = new fabric.Imagearea({ + left: 100, + top: 100, + width: 100, + height: 100, + lockRotation: true, + fill: '#666', + content: '', + }); + rect.setControlsVisibility({'mtr': false}); + editor.fabric.add(rect); + editor._create_savepoint(); + return rect; + }, + _add_qrcode: function () { var rect = new fabric.Barcodearea({ left: 100, @@ -795,6 +869,7 @@ var editor = { editor.$cva = $("#editor-canvas-area"); editor._load_pdf(); $("#editor-add-qrcode, #editor-add-qrcode-lead").click(editor._add_qrcode); + $("#editor-add-image").click(editor._add_imagearea); $("#editor-add-text").click(editor._add_text); $("#editor-add-poweredby").click(function() {editor._add_poweredby("dark")}); editor.$cva.get(0).tabIndex = 1000; diff --git a/src/pretix/static/pretixcontrol/scss/pdfeditor.css b/src/pretix/static/pretixcontrol/scss/pdfeditor.css index 3f7136861..fd92a1055 100644 --- a/src/pretix/static/pretixcontrol/scss/pdfeditor.css +++ b/src/pretix/static/pretixcontrol/scss/pdfeditor.css @@ -15,14 +15,18 @@ body { } #toolbox .position, #toolbox .squaresize, +#toolbox .rectsize, #toolbox .poweredby, #toolbox[data-type] .pdf-info, #toolbox .text, +#toolbox .imagecontent, #toolbox .object-buttons { display: none; } #toolbox[data-type] .position, #toolbox[data-type=barcodearea] .squaresize, +#toolbox[data-type=imagearea] .rectsize, +#toolbox[data-type=imagearea] .imagecontent, #toolbox[data-type=poweredby] .poweredby, #toolbox[data-type=text] .text, #toolbox[data-type=textarea] .text,