diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 889a7c4e34..ca96f03d95 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -37,6 +37,7 @@ import hashlib import itertools import logging import os +import re import subprocess import tempfile import uuid @@ -54,6 +55,7 @@ from django.utils.functional import SimpleLazyObject from django.utils.html import conditional_escape from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ +from i18nfield.strings import LazyI18nString from PyPDF2 import PdfFileReader from pytz import timezone from reportlab.graphics import renderPDF @@ -202,6 +204,11 @@ DEFAULT_VARIABLES = OrderedDict(( "editor_sample": 'foo@bar.com', "evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '') }), + ("pseudonymization_id", { + "label": _("Pseudonymization ID (lead scanning)"), + "editor_sample": "GG89JUJDTA", + "evaluate": lambda orderposition, order, event: orderposition.pseudonymization_id, + }), ("event_name", { "label": _("Event name"), "editor_sample": _("Sample event name"), @@ -616,12 +623,14 @@ class Renderer: preserveAspectRatio=True, anchor='n', mask='auto') - def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict): + def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): content = o.get('content', 'secret') if content == 'secret': + # do not use get_text_content because it uses a shortened version of secret + # and does not deal with our default value here properly content = op.secret - elif content == 'pseudonymization_id': - content = op.pseudonymization_id + else: + content = self._get_text_content(op, order, o) level = 'H' if len(content) > 32: @@ -648,20 +657,45 @@ class Renderer: return self._get_text_content(op, order, o, True) ev = self._get_ev(op, order) + if not o['content']: return '(error)' - if o['content'] == 'other': - return o['text'] + + if o['content'] == 'other' or o['content'] == 'other_i18n': + if o['content'] == 'other_i18n': + text = str(LazyI18nString(o['text_i18n'])) + else: + text = o['text'] + + def replace(x): + if x.group(1) not in self.variables: + return x.group(0) + if x.group(1) == 'secret': + # Do not use shortened version + return op.secret + try: + return self.variables[x.group(1)]['evaluate'](op, order, ev) + except: + logger.exception('Failed to process variable.') + return '(error)' + + # We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this + # 1:1 on other platforms that render PDFs through our API (libpretixprint) + return re.sub(r'\{([a-zA-Z0-9_]+)\}', replace, text) + elif o['content'].startswith('itemmeta:'): return op.item.meta_data.get(o['content'][9:]) or '' + elif o['content'].startswith('meta:'): return ev.meta_data.get(o['content'][5:]) or '' + elif o['content'] in self.variables: try: return self.variables[o['content']]['evaluate'](op, order, ev) except: logger.exception('Failed to process variable.') return '(error)' + return '' def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): @@ -754,20 +788,30 @@ class Renderer: p.drawOn(canvas, 0, -h - ad[1]) canvas.restoreState() - def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True): - 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": - self._draw_poweredby(canvas, op, o) - if self.bg_pdf: - canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3])) - if show_page: - canvas.showPage() + def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True, only_page=None): + page_count = self.bg_pdf.getNumPages() + + if not only_page and not show_page: + raise ValueError("only_page=None and show_page=False cannot be combined") + + for page in range(page_count): + if only_page and only_page != page + 1: + continue + for o in self.layout: + if o.get('page', 1) != page + 1: + continue + if o['type'] == "barcodearea": + self._draw_barcodearea(canvas, op, order, 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": + self._draw_poweredby(canvas, op, o) + if self.bg_pdf: + canvas.setPageSize((self.bg_pdf.getPage(page).mediaBox[2], self.bg_pdf.getPage(page).mediaBox[3])) + if show_page: + canvas.showPage() def render_background(self, buffer, title=_('Ticket')): if settings.PDFTK: @@ -780,7 +824,7 @@ class Renderer: subprocess.run([ settings.PDFTK, os.path.join(d, 'front.pdf'), - 'background', + 'multibackground', os.path.join(d, 'back.pdf'), 'output', os.path.join(d, 'out.pdf'), @@ -794,8 +838,8 @@ class Renderer: new_pdf = PdfFileReader(buffer) output = PdfFileWriter() - for page in new_pdf.pages: - bg_page = copy.copy(self.bg_pdf.getPage(0)) + for i, page in enumerate(new_pdf.pages): + bg_page = copy.copy(self.bg_pdf.getPage(i)) bg_page.mergePage(page) output.addPage(bg_page) diff --git a/src/pretix/control/templates/pretixcontrol/pdf/index.html b/src/pretix/control/templates/pretixcontrol/pdf/index.html index c258bab304..ee35023d16 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/index.html +++ b/src/pretix/control/templates/pretixcontrol/pdf/index.html @@ -23,7 +23,7 @@
-
+
@@ -48,6 +48,8 @@ {% trans "Editor" %}
+
{% trans "Upload custom background" %} - +
@@ -204,6 +206,14 @@ {% endblocktrans %}

+
@@ -357,9 +367,9 @@
-
+
-
+
+
+ {% for l in request.event.settings.locales %} + + {% endfor %} +
@@ -401,13 +417,20 @@ {% trans "QR code for Lead Scanning" %} + - diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index cf67bc5a2c..1837b3da59 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -23,6 +23,7 @@ import json import logging import mimetypes from datetime import timedelta +from decimal import Decimal from io import BytesIO from django.conf import settings @@ -38,7 +39,8 @@ from django.utils.crypto import get_random_string from django.utils.timezone import now from django.utils.translation import gettext as _ from django.views.generic import TemplateView -from PyPDF2 import PdfFileWriter +from PyPDF2 import PdfFileReader, PdfFileWriter +from PyPDF2.utils import PdfReadError from reportlab.lib.units import mm from pretix.base.i18n import language @@ -82,15 +84,15 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): return None, f def _get_preview_position(self): - item = self.request.event.items.create(name=_("Sample product"), default_price=42.23, + item = self.request.event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), description=_("Sample product description")) - item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=23.40) + item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) from pretix.base.models import Order order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(), email='sample@pretix.eu', locale=self.request.event.settings.locale, - expires=now(), code="PREVIEW1234", total=119) + expires=now(), code="PREVIEW1234", total=Decimal('119.00')) scheme = PERSON_NAME_SCHEMES[self.request.event.settings.name_scheme] sample = {k: str(v) for k, v in scheme['sample'].items()} @@ -191,6 +193,17 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): c.file = fileobj c.save() c.refresh_from_db() + + try: + bg_bytes = c.file.read() + PdfFileReader(BytesIO(bg_bytes), strict=False) + except PdfReadError as e: + return JsonResponse({ + "status": "error", + "error": _('Unfortunately, we were unable to process this PDF file ({reason}).').format( + reason=str(e) + ) + }) return JsonResponse({ "status": "ok", "id": c.id, diff --git a/src/pretix/plugins/badges/exporters.py b/src/pretix/plugins/badges/exporters.py index 6effab993d..29149f2ed2 100644 --- a/src/pretix/plugins/badges/exporters.py +++ b/src/pretix/plugins/badges/exporters.py @@ -181,7 +181,7 @@ def render_pdf(event, positions, opt): offsety = opt['margins'][2] + (opt['rows'] - 1 - i // opt['cols']) * opt['offsets'][1] p.translate(offsetx, offsety) with language(op.order.locale, op.order.event.settings.region): - r.draw_page(p, op.order, op, show_page=False) + r.draw_page(p, op.order, op, show_page=False, only_page=1) p.translate(-offsetx, -offsety) if opt['pagesize']: diff --git a/src/pretix/static/pretixcontrol/js/ui/editor.js b/src/pretix/static/pretixcontrol/js/ui/editor.js index 372b09211e..8fe3672d3f 100644 --- a/src/pretix/static/pretixcontrol/js/ui/editor.js +++ b/src/pretix/static/pretixcontrol/js/ui/editor.js @@ -66,9 +66,11 @@ fabric.Barcodearea = fabric.util.createClass(fabric.Rect, { ctx.font = '16px Helvetica'; ctx.fillStyle = '#fff'; if (this.content === "pseudonymization_id") { - ctx.fillText(gettext('Lead Scan QR'), -this.width / 2, -this.height / 2 + 20); - } else { + ctx.fillText(this.content, -this.width / 2, -this.height / 2 + 20); + } else if (!this.content || this.content === "secret") { ctx.fillText(gettext('Check-in QR'), -this.width / 2, -this.height / 2 + 20); + } else { + ctx.fillText(this.content, -this.width / 2, -this.height / 2 + 20); } }, }); @@ -102,11 +104,15 @@ var editor = { objects: [], history: [], clipboard: [], + pdf: null, pdf_page: null, + pdf_page_number: 1, + pdf_page_count: 1, pdf_scale: 1, pdf_viewport: null, _history_pos: 0, _history_modification_in_progress: false, + _other_page_objects: [], dirty: false, pdf_url: null, uploaded_file_id: null, @@ -130,7 +136,7 @@ var editor = { }, dump: function (objs) { - var d = []; + var d = !objs ? JSON.parse(JSON.stringify(editor._other_page_objects)) : []; objs = objs || editor.fabric.getObjects(); for (var i in objs) { @@ -149,6 +155,7 @@ var editor = { } d.push({ type: "textarea", + page: editor.pdf_page_number, locale: $("#pdf-info-locale").val(), left: editor._px2mm(left).toFixed(2), bottom: editor._px2mm(bottom).toFixed(2), @@ -162,12 +169,14 @@ var editor = { downward: o.downward || false, content: o.content, text: o.text, + text_i18n: o.text_i18n || {}, rotation: o.angle, align: o.textAlign, }); } else if (o.type === "imagearea") { d.push({ type: "imagearea", + page: editor.pdf_page_number, 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), @@ -177,15 +186,18 @@ var editor = { } else if (o.type === "barcodearea") { d.push({ type: "barcodearea", + page: editor.pdf_page_number, left: editor._px2mm(left).toFixed(2), bottom: editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - top).toFixed(2), size: editor._px2mm(o.height * o.scaleY).toFixed(2), content: o.content, + text: o.text, nowhitespace: o.nowhitespace || false, }); } else if (o.type === "poweredby") { d.push({ type: "poweredby", + page: editor.pdf_page_number, left: editor._px2mm(left).toFixed(2), bottom: editor._px2mm(editor.pdf_viewport.height - o.height * o.scaleY - top).toFixed(2), size: editor._px2mm(o.height * o.scaleY).toFixed(2), @@ -197,6 +209,11 @@ var editor = { }, _add_from_data: function (d) { + var targetPage = d.page || 1; + if (targetPage !== editor.pdf_page_number) { + editor._other_page_objects.push(d); + return + } if (d.type === "barcodearea") { o = editor._add_qrcode(); o.content = d.content; @@ -230,6 +247,9 @@ var editor = { } if (d.content === "other") { o.setText(d.text); + } else if (d.content === "other_i18n") { + o.text_i18n = d.text_i18n + o.setText(d.text_i18n[Object.keys(d.text_i18n)[0]]); } else { o.setText(editor._get_text_sample(d.content)); } @@ -251,6 +271,7 @@ var editor = { load: function(data) { editor.fabric.clear(); + editor._other_page_objects = []; for (var i in data) { var d = data[i], o; editor._add_from_data(d); @@ -268,6 +289,71 @@ var editor = { return $('#toolbox-content option[value='+key+']').attr('data-sample') || ''; }, + _load_page: function (page_number, dump) { + var previous_dump = editor._fabric_loaded ? editor.dump() : []; + + // Fetch the required page + editor.pdf.getPage(page_number).then(function (page) { + console.log('Page loaded'); + var canvas = document.getElementById('pdf-canvas'); + + var scale = editor.$cva.width() / page.getViewport(1.0).width; + var viewport = page.getViewport(scale); + + // Prepare canvas using PDF page dimensions + var context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + canvas.height = viewport.height; + canvas.width = viewport.width; + + editor.pdf_page = page; + editor.pdf_scale = scale; + editor.pdf_viewport = viewport; + + // Render PDF page into canvas context + var renderContext = { + canvasContext: context, + viewport: viewport + }; + var renderTask = page.render(renderContext); + renderTask.then(function () { + editor.pdf_page_number = page_number + editor._init_page_nav(); + + console.log('Page rendered'); + if (dump || !editor._fabric_loaded) { + editor._init_fabric(dump); + } else { + editor.load(previous_dump); + } + }); + }); + }, + + _init_page_nav: function () { + if (editor.pdf_page_count === 1) { + $("#page_nav").hide(); + } else { + $("#page_nav").html(""); + for (i = 1; i <= editor.pdf_page_count; i++) { + var $li = $("
  • ").addClass("nav-item"); + var $a = $("").text(i).attr("href", "#").attr("data-page", i).appendTo($li); + if (i === editor.pdf_page_number) { + $li.addClass("active") + } + $("#page_nav").append($li) + $a.on("click", function (event) { + console.log("switch to page", $(this).attr("data-page")); + editor.fabric.deactivateAll(); + editor._load_page(parseInt($(this).attr("data-page"))); + event.preventDefault(); + return true; + }) + } + $("#page_nav").show(); + } + }, + _load_pdf: function (dump) { // TODO: Loading indicators var url = editor.pdf_url; @@ -279,36 +365,13 @@ var editor = { loadingTask.promise.then(function (pdf) { console.log('PDF loaded'); - // Fetch the first page - var pageNumber = 1; - pdf.getPage(pageNumber).then(function (page) { - console.log('Page loaded'); - var canvas = document.getElementById('pdf-canvas'); - - var scale = editor.$cva.width() / page.getViewport(1.0).width; - var viewport = page.getViewport(scale); - - // Prepare canvas using PDF page dimensions - var context = canvas.getContext('2d'); - context.clearRect(0, 0, canvas.width, canvas.height); - canvas.height = viewport.height; - canvas.width = viewport.width; - - editor.pdf_page = page; - editor.pdf_scale = scale; - editor.pdf_viewport = viewport; - - // Render PDF page into canvas context - var renderContext = { - canvasContext: context, - viewport: viewport - }; - var renderTask = page.render(renderContext); - renderTask.then(function () { - console.log('Page rendered'); - editor._init_fabric(dump); - }); - }); + editor.pdf = pdf; + editor.pdf_page_count = pdf.numPages; + if (editor.pdf_page_count > 10) { + alert('Please do not upload files with more than 10 pages for performance reasons.') + } + editor._init_page_nav(); + editor._load_page(1, dump); }, function (reason) { var msg = gettext('The PDF background file could not be loaded for the following reason:'); editor._error(msg + ' ' + reason); @@ -332,6 +395,7 @@ var editor = { editor._update_toolbox(); $("#toolbox-content-other").hide(); + $("#toolbox-content-other-i18n").hide(); $(".add-buttons button").prop('disabled', false); if (dump) { @@ -422,14 +486,23 @@ var editor = { $("#toolbox").find("button[data-action=right]").toggleClass('active', o.textAlign === 'right'); $("#toolbox-textwidth").val(editor._px2mm(o.width).toFixed(2)); $("#toolbox-textrotation").val((o.angle || 0.0).toFixed(1)); - if (o.type === "textarea") { - $("#toolbox-content").val(o.content); - $("#toolbox-content-other").toggle($("#toolbox-content").val() === "other"); - if (o.content === "other") { - $("#toolbox-content-other").val(o.text); - } else { - $("#toolbox-content-other").val(""); - } + } + + if (o.type === "textarea" || o.type === "barcodearea") { + if (!o.content && o.type == "barcodearea") { + o.content = "secret"; + } + $("#toolbox-content").val(o.content); + $("#toolbox-content-other").toggle($("#toolbox-content").val() === "other"); + $("#toolbox-content-other-i18n").toggle($("#toolbox-content").val() === "other_i18n"); + if (o.content === "other") { + $("#toolbox-content-other").val(o.text); + } else if (o.content === "other_i18n") { + $("#toolbox-content-other-i18n textarea").each(function () { + $(this).val(o.text_i18n[$(this).attr("lang")] || ''); + }); + } else { + $("#toolbox-content-other").val(""); } } }, @@ -461,6 +534,20 @@ var editor = { o.setScaleY(1); o.set('top', new_top) o.nowhitespace = $("#toolbox-qrwhitespace").prop("checked") || false; + + $("#toolbox-content-other").toggle($("#toolbox-content").val() === "other"); + $("#toolbox-content-other-i18n").toggle($("#toolbox-content").val() === "other_i18n"); + o.content = $("#toolbox-content").val(); + if ($("#toolbox-content").val() === "other") { + o.text = $("#toolbox-content-other").val(); + } else if ($("#toolbox-content").val() === "other_i18n") { + o.text_i18n = {} + $("#toolbox-content-other-i18n textarea").each(function () { + o.text_i18n[$(this).attr("lang")] = $(this).val(); + }); + } else { + o.text = editor._get_text_sample($("#toolbox-content").val()); + } } else if (o.type === "imagearea") { var new_w = editor._mm2px($("#toolbox-width").val()); var new_h = editor._mm2px($("#toolbox-height").val()); @@ -503,9 +590,16 @@ var editor = { o.downward = $("#toolbox").find("button[data-action=downward]").is('.active'); o.rotate(parseFloat($("#toolbox-textrotation").val())); $("#toolbox-content-other").toggle($("#toolbox-content").val() === "other"); + $("#toolbox-content-other-i18n").toggle($("#toolbox-content").val() === "other_i18n"); o.content = $("#toolbox-content").val(); if ($("#toolbox-content").val() === "other") { o.setText($("#toolbox-content-other").val()); + } else if ($("#toolbox-content").val() === "other_i18n") { + o.text_i18n = {} + $("#toolbox-content-other-i18n textarea").each(function () { + o.text_i18n[$(this).attr("lang")] = $(this).val(); + }); + o.setText($("#toolbox-content-other-i18n textarea").first().val()); } else { o.setText(editor._get_text_sample($("#toolbox-content").val())); } @@ -618,6 +712,7 @@ var editor = { lockUniScaling: true, fill: '#666', content: $(this).attr("data-content"), + text: '', nowhitespace: true, }); rect.setControlsVisibility({'mtr': false}); @@ -664,6 +759,7 @@ var editor = { editor._history_modification_in_progress = true; var objs = []; for (var i in editor.clipboard) { + editor.clipboard[i].page = editor.pdf_page_number; objs.push(editor._add_from_data(editor.clipboard[i])); } editor.fabric.discardActiveObject(); @@ -729,6 +825,11 @@ var editor = { case 46: /* Delete */ editor._delete(); break; + case 65: /* A */ + if (e.ctrlKey) { + editor._selectAll(); + } + break; case 89: /* Y */ if (e.ctrlKey) { editor._redo(); @@ -775,6 +876,15 @@ var editor = { editor.dirty = true; }, + _selectAll: function () { + var group = new fabric.Group(editor.fabric.getObjects(), { + originX: 'center', + originY: 'center', + }); + group.setCoords(); + editor.fabric.setActiveGroup(group); + }, + _undo: function undo() { if (editor._history_pos < editor.history.length - 1) { editor._history_modification_in_progress = true; @@ -827,6 +937,7 @@ var editor = { d = editor.dump(); editor.fabric.dispose(); editor._load_pdf(d); + $(".background-download-button").attr("href", url); }, _source_show: function () { @@ -873,7 +984,7 @@ var editor = { editor.$fcv = $("#fabric-canvas"); editor.$cva = $("#editor-canvas-area"); editor._load_pdf(); - $("#editor-add-qrcode, #editor-add-qrcode-lead").click(editor._add_qrcode); + $("#editor-add-qrcode, #editor-add-qrcode-lead, #editor-add-qrcode-other").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")}); diff --git a/src/pretix/static/pretixcontrol/scss/pdfeditor.css b/src/pretix/static/pretixcontrol/scss/pdfeditor.css index fd92a1055a..c4a84e5e65 100644 --- a/src/pretix/static/pretixcontrol/scss/pdfeditor.css +++ b/src/pretix/static/pretixcontrol/scss/pdfeditor.css @@ -25,6 +25,7 @@ body { } #toolbox[data-type] .position, #toolbox[data-type=barcodearea] .squaresize, +#toolbox[data-type=barcodearea] .textcontent, #toolbox[data-type=imagearea] .rectsize, #toolbox[data-type=imagearea] .imagecontent, #toolbox[data-type=poweredby] .poweredby, @@ -69,3 +70,10 @@ body { margin-top: 10px; margin-bottom: 0; } +.panel-pdf-editor .panel-body { + padding: 0; +} +.panel-pdf-editor .panel-body .nav-pills { + padding: 15px; + background: #eee; +}