From 022f44ad00b176e8ad46d860f68bbc9e0f0b8f32 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 7 Aug 2024 11:26:47 +0200 Subject: [PATCH] PDF editor: New text element implementation (#4246) * draft * almost working * Widgth adjustment * Fix crash on empty text * Change default layouts * Fix editor bugs * Update src/pretix/control/templates/pretixcontrol/pdf/index.html Co-authored-by: Richard Schreiber * Show deprecated text on old text * lockScalingFlip * Regroup editor controls * Update src/pretix/static/pretixcontrol/js/ui/main.js Co-authored-by: Richard Schreiber * Update src/pretix/static/pretixcontrol/js/ui/main.js Co-authored-by: Richard Schreiber * Update src/pretix/static/pretixcontrol/js/ui/main.js Co-authored-by: Richard Schreiber * Update src/pretix/static/pretixcontrol/js/ui/editor.js Co-authored-by: Richard Schreiber * Increase default height even further * Add a small version warning * Update src/pretix/control/templates/pretixcontrol/pdf/index.html Co-authored-by: Richard Schreiber * Update src/pretix/control/templates/pretixcontrol/pdf/index.html Co-authored-by: Richard Schreiber --------- Co-authored-by: Richard Schreiber --- src/pretix/base/pdf.py | 55 ++- .../templates/pretixcontrol/pdf/index.html | 112 +++-- src/pretix/plugins/badges/templates.py | 76 +-- src/pretix/plugins/ticketoutputpdf/models.py | 458 +++++++++++------- .../static/pretixcontrol/js/ui/editor.js | 284 ++++++++++- src/pretix/static/pretixcontrol/js/ui/main.js | 38 +- .../static/pretixcontrol/scss/pdfeditor.css | 15 + 7 files changed, 775 insertions(+), 263 deletions(-) diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index ee11163bc7..5e9834ead8 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -956,7 +956,7 @@ class Renderer: ) canvas.restoreState() - def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): + def _text_paragraph(self, op: OrderPosition, order: Order, o: dict, legacy_lineheight=False, override_fontsize=None): font = o['fontfamily'] # Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they @@ -970,12 +970,13 @@ class Renderer: if o['italic']: font += ' I' + fontsize = override_fontsize if override_fontsize is not None else float(o['fontsize']) try: - ad = getAscentDescent(font, float(o['fontsize'])) + ad = getAscentDescent(font, fontsize) except KeyError: # font not known, fall back logger.warning(f'Use of unknown font "{font}"') font = 'Open Sans' - ad = getAscentDescent(font, float(o['fontsize'])) + ad = getAscentDescent(font, fontsize) align_map = { 'left': TA_LEFT, @@ -985,16 +986,17 @@ class Renderer: # lineheight display differs from browser canvas. This calc is just empirical values to get # reportlab render similarly to browser canvas. # for backwards compatability use „uncorrected“ lineheight of 1.0 instead of 1.15 - lineheight = float(o['lineheight']) * 1.15 if 'lineheight' in o else 1.0 + lineheight = float(o['lineheight']) * 1.15 if not legacy_lineheight or 'lineheight' in o else 1.0 style = ParagraphStyle( name=uuid.uuid4().hex, fontName=font, - fontSize=float(o['fontsize']), - leading=lineheight * float(o['fontsize']), + fontSize=fontsize, + leading=lineheight * fontsize, # for backwards compatability use autoLeading if no lineheight is given - autoLeading='off' if 'lineheight' in o else 'max', + autoLeading='off' if not legacy_lineheight or 'lineheight' in o else 'max', textColor=Color(o['color'][0] / 255, o['color'][1] / 255, o['color'][2] / 255), - alignment=align_map[o['align']] + alignment=align_map[o['align']], + splitLongWords=o.get('splitlongwords', True), ) # add an almost-invisible space   after hyphens as word-wrap in ReportLab only works on space chars text = conditional_escape( @@ -1013,6 +1015,41 @@ class Renderer: logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) p = Paragraph(text, style=style) + return p, ad, lineheight + + def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): + fontsize = float(o['fontsize']) + height = float(o['height']) * mm + width = float(o['width']) * mm + while True: + p, ad, lineheight = self._text_paragraph(op, order, o, override_fontsize=fontsize) + w, h = p.wrapOn(canvas, width, 1000 * mm) + widths = p.getActualLineWidths0() + if not widths: + break + actual_w = max(widths) + if not o.get('autoresize', False) or (h <= height and actual_w <= width) or fontsize <= 1.0: + break + if h > height: # we can do larger steps for height + fontsize -= max(1.0, fontsize * .1) + else: + fontsize -= max(.25, fontsize * .025) + + canvas.saveState() + # The ascent/descent offsets here are not really proven to be correct, they're just empirical values to get + # reportlab render similarly to browser canvas. + canvas.translate(float(o['left']) * mm, float(o['bottom']) * mm + height) + canvas.rotate(o.get('rotation', 0) * -1) + if o.get('verticalalign', 'top') == 'top': + p.drawOn(canvas, 0, - h) + elif o.get('verticalalign', 'top') == 'middle': + p.drawOn(canvas, 0, (-height - h) / 2) + elif o.get('verticalalign', 'top') == 'bottom': + p.drawOn(canvas, 0, -height) + canvas.restoreState() + + def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): + p, ad, lineheight = self._text_paragraph(op, order, o, legacy_lineheight=True) w, h = p.wrapOn(canvas, float(o['width']) * mm, 1000 * mm) # p_size = p.wrap(float(o['width']) * mm, 1000 * mm) canvas.saveState() @@ -1051,6 +1088,8 @@ class Renderer: self._draw_barcodearea(canvas, op, order, o) elif o['type'] == "imagearea": self._draw_imagearea(canvas, op, order, o) + elif o['type'] == "textcontainer": + self._draw_textcontainer(canvas, op, order, o) elif o['type'] == "textarea": self._draw_textarea(canvas, op, order, o) elif o['type'] == "poweredby": diff --git a/src/pretix/control/templates/pretixcontrol/pdf/index.html b/src/pretix/control/templates/pretixcontrol/pdf/index.html index d9198fa9ff..72eaee686c 100644 --- a/src/pretix/control/templates/pretixcontrol/pdf/index.html +++ b/src/pretix/control/templates/pretixcontrol/pdf/index.html @@ -177,7 +177,7 @@ {% if name %}
-
+
@@ -185,11 +185,11 @@

-
+
-
+
@@ -227,7 +227,7 @@

-
+
@@ -247,7 +247,7 @@
-
+
{% for varname, var in variables.items %} {% if not var.hidden %} @@ -293,31 +293,31 @@

-
+
-
+
-
+
-
+
-
+
@@ -335,13 +335,13 @@
-
-
+
+
-
+
@@ -349,7 +349,7 @@

-
+
-
+
-
- +
+
+ + + +

-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
@@ -423,9 +469,13 @@ {% trans "Add a new object" %}
+
-
{% csrf_token %} @@ -472,6 +521,13 @@
+

 

+
+ {% blocktrans trimmed with print_version="2.18" scan_version="1.22" %} + This layout uses new features. If you print from your device, make sure you use pretixPRINT version + {{ print_version }} (or newer) or pretixSCAN Desktop version {{ scan_version }} (or newer). + {% endblocktrans %} +
diff --git a/src/pretix/plugins/badges/templates.py b/src/pretix/plugins/badges/templates.py index 2293350ac5..b45a1644b5 100644 --- a/src/pretix/plugins/badges/templates.py +++ b/src/pretix/plugins/badges/templates.py @@ -30,11 +30,11 @@ def _simple_template(w, h): company_size = name_size - 2 return [ { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", "left": "5.00", - "bottom": "%.2f" % (((h - company_size * 1.5 - name_size) / 2 + company_size * 1.5) / mm), + "bottom": "%.2f" % (h / mm / 2 + 2), "fontsize": name_size, "lineheight": "1", "color": [0, 0, 0, 1], @@ -42,19 +42,22 @@ def _simple_template(w, h): "bold": True, "italic": False, "width": "%.2f" % (w / mm - 10), - "downward": False, + "height": "%.2f" % (h / mm / 2 - 7), "content": "attendee_name", - "text": "John Doe", + "text": "Dr John Doe", "text_i18n": {}, "rotation": 0, "align": "center", + "verticalalign": "bottom", + "autoresize": True, + "splitlongwords": False, }, { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", "left": "5.00", - "bottom": "%.2f" % ((((h - company_size * 1.5 - name_size) / 2) + company_size) / mm), + "bottom": "5.00", "fontsize": company_size, "lineheight": "1", "color": [0, 0, 0, 1], @@ -62,12 +65,15 @@ def _simple_template(w, h): "bold": False, "italic": False, "width": "%.2f" % (w / mm - 10), - "downward": True, + "height": "%.2f" % (h / mm / 2 - 7), "content": "attendee_company", "text": "Sample company", "text_i18n": {}, "rotation": 0, "align": "center", + "verticalalign": "top", + "autoresize": True, + "splitlongwords": False, }, ] @@ -94,15 +100,17 @@ TEMPLATES = { "layout": _simple_template(*pagesizes.portrait(pagesizes.A7)), }, "82x203butterfly": { - "label": format_lazy(_("{width} x {height} mm butterfly badge"), width=82, height=203), + "label": format_lazy( + _("{width} x {height} mm butterfly badge"), width=82, height=203 + ), "pagesize": (82 * mm, 203 * mm), "layout": [ { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", "left": "5.00", - "bottom": "152.55", + "bottom": "153.00", "fontsize": "20.0", "lineheight": "1", "color": [0, 0, 0, 1], @@ -110,19 +118,22 @@ TEMPLATES = { "bold": True, "italic": False, "width": "72.00", - "downward": False, + "height": "20.00", "content": "attendee_name", - "text": "John Doe", + "text": "Dr John Doe", "text_i18n": {}, "rotation": 0, "align": "center", + "verticalalign": "bottom", + "autoresize": True, + "splitlongwords": False, }, { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", "left": "5.00", - "bottom": "144.55", + "bottom": "132.10", "fontsize": "18.0", "lineheight": "1", "color": [0, 0, 0, 1], @@ -130,19 +141,22 @@ TEMPLATES = { "bold": False, "italic": False, "width": "72.00", - "downward": False, + "height": "20.00", "content": "attendee_company", "text": "Sample company", "text_i18n": {}, "rotation": 0, "align": "center", + "verticalalign": "top", + "autoresize": True, + "splitlongwords": False, }, { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", - "left": "77.10", - "bottom": "34.68", + "left": "76.97", + "bottom": "10.86", "fontsize": "20.0", "lineheight": "1", "color": [0, 0, 0, 1], @@ -150,19 +164,22 @@ TEMPLATES = { "bold": True, "italic": False, "width": "72.00", - "downward": False, + "height": "20.00", "content": "attendee_name", - "text": "John Doe", + "text": "Dr John Doe", "text_i18n": {}, - "rotation": 180, + "rotation": -180, "align": "center", + "verticalalign": "bottom", + "autoresize": True, + "splitlongwords": False, }, { - "type": "textarea", + "type": "textcontainer", "page": 1, "locale": "", - "left": "77.06", - "bottom": "44.28", + "left": "77.07", + "bottom": "31.76", "fontsize": "18.0", "lineheight": "1", "color": [0, 0, 0, 1], @@ -170,12 +187,15 @@ TEMPLATES = { "bold": False, "italic": False, "width": "72.00", - "downward": False, + "height": "20.00", "content": "attendee_company", "text": "Sample company", "text_i18n": {}, - "rotation": 180, + "rotation": -180, "align": "center", + "verticalalign": "top", + "autoresize": True, + "splitlongwords": False, }, ], }, @@ -235,7 +255,9 @@ TEMPLATES = { "layout": _simple_template(40 * mm, 40 * mm), }, "88.9x33.87": { - "label": format_lazy(_("{width} x {height} mm label"), width=88.9, height=33.87), + "label": format_lazy( + _("{width} x {height} mm label"), width=88.9, height=33.87 + ), "pagesize": (88.9 * mm, 33.87 * mm), "layout": _simple_template(88.9 * mm, 33.87 * mm), }, diff --git a/src/pretix/plugins/ticketoutputpdf/models.py b/src/pretix/plugins/ticketoutputpdf/models.py index 8619b627ed..337e69d534 100644 --- a/src/pretix/plugins/ticketoutputpdf/models.py +++ b/src/pretix/plugins/ticketoutputpdf/models.py @@ -27,191 +27,279 @@ from django.utils.translation import gettext_lazy as _ from pretix.base.models import LoggedModel -DEFAULT_TICKET_LAYOUT = '''[{ - "type":"textarea", - "left":"17.50", - "bottom":"274.60", - "fontsize":"16.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"175.00", - "content":"event_name", - "text":"Sample event name", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"262.90", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"110.00", - "content":"itemvar", - "text":"Sample product – sample variation", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"252.50", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"110.00", - "content":"attendee_name", - "text":"John Doe", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"242.10", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"110.00", - "content":"event_begin", - "text":"2016-05-31 20:00", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"231.70", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"110.00", - "content":"seat", - "text":"Ground floor, Row 3, Seat 4", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"204.80", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"110.00", - "content":"event_location", - "text":"Random City", - "align":"left" -}, -{ - "type":"textarea", - "left":"17.50", - "bottom":"194.50", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"30.00", - "content":"order", - "text":"A1B2C", - "align":"left" -}, -{ - "type":"textarea", - "left":"52.50", - "bottom":"194.50", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"45.00", - "content":"price", - "text":"123.45 EUR", - "align":"right" -}, -{ - "type":"textarea", - "left":"102.50", - "bottom":"194.50", - "fontsize":"13.0", - "color":[ - 0, - 0, - 0, - 1 - ], - "fontfamily":"Open Sans", - "bold":false, - "italic":false, - "width":"90.00", - "content":"secret", - "text":"tdmruoekvkpbv1o2mv8xccvqcikvr58u", - "align":"left" -}, -{ - "type":"barcodearea", - "left":"130.40", - "bottom":"204.50", - "size":"64.00", - "content":"secret" -}, -{ - "type":"poweredby", - "left":"88.72", - "bottom":"10.00", - "size":"20.00", - "content":"dark" -}]''' +DEFAULT_TICKET_LAYOUT = '''[ + { + "type": "barcodearea", + "page": 1, + "left": "130.40", + "bottom": "204.50", + "size": "64.00", + "content": "secret", + "text": "", + "text_i18n": {}, + "nowhitespace": false + }, + { + "type": "poweredby", + "page": 1, + "left": "88.72", + "bottom": "10.00", + "size": "20.00", + "content": "dark" + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "272.09", + "fontsize": "14.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "177.07", + "height": "11.80", + "content": "event_name", + "text": "Sample event name", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "261.77", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "113.03", + "height": "7.83", + "content": "itemvar", + "text": "Sample product – sample variation", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "251.30", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "113.03", + "height": "7.83", + "content": "attendee_name", + "text": "Dr John Doe", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "240.30", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "113.03", + "height": "7.83", + "content": "event_begin", + "text": "2017-05-31 20:00", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "231.30", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "113.03", + "height": "7.83", + "content": "seat", + "text": "Ground floor, Row 3, Seat 4", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.35", + "bottom": "203.43", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "113.03", + "height": "25.70", + "content": "event_location", + "text": "Random City", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "bottom", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "101.50", + "bottom": "193.33", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "91.93", + "height": "7.83", + "content": "secret", + "text": "tdmruoekvkpbv1o2mv8xccvqcikvr58u", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "51.50", + "bottom": "193.33", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "47.38", + "height": "7.83", + "content": "price", + "text": "123.45 EUR", + "text_i18n": {}, + "rotation": 0, + "align": "right", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + }, + { + "type": "textcontainer", + "page": 1, + "locale": "", + "left": "16.50", + "bottom": "193.33", + "fontsize": "13.0", + "lineheight": "1", + "color": [ + 0, + 0, + 0, + 1 + ], + "fontfamily": "Open Sans", + "bold": false, + "italic": false, + "width": "32.32", + "height": "7.83", + "content": "order", + "text": "A1B2C", + "text_i18n": {}, + "rotation": 0, + "align": "left", + "verticalalign": "middle", + "autoresize": true, + "splitlongwords": true + } +]''' def bg_name(instance, filename: str) -> str: diff --git a/src/pretix/static/pretixcontrol/js/ui/editor.js b/src/pretix/static/pretixcontrol/js/ui/editor.js index 426e19ca26..a5e731254d 100644 --- a/src/pretix/static/pretixcontrol/js/ui/editor.js +++ b/src/pretix/static/pretixcontrol/js/ui/editor.js @@ -47,6 +47,105 @@ fabric.Imagearea = fabric.util.createClass(fabric.Rect, { fabric.Imagearea.fromObject = function (object, callback, forceAsync) { return fabric.Object._fromObject('Imagearea', object, callback, forceAsync); }; +fabric.Textcontainer = fabric.util.createClass(fabric.Rect, { + type: 'textcontainer', + + initialize: function (text, options) { + options || (options = {}); + + this.callSuper('initialize', options); + + //this.textbox = new fabric.Textbox(text, JSON.parse(JSON.stringify(options))); + this.set('content', options.content || ''); + + this.cacheProperties.push( + "width", "height", "fontSize", "lineHeight", "fill", "fontFamily", "fontWeight", + "fontStyle", "text", "textAlign", "splitLongWords", "splitByGrapheme", "verticalAlign", + "autoResize", + ) + }, + + toObject: function (propertiesToInclude) { + return this.callSuper('toObject', ['content'].concat(propertiesToInclude)); + }, + + _internalTextbox: function () { + var fontSize = parseFloat(this.fontSize); + var text = (this.text || "").replace("-", "-\u200B"); + while (true) { + var tmptext = new fabric.Textbox(text, { + _wordJoiners: /[ \t\r\u200B]/u, + left: 0, + top: 0, + originY: 'top', + originX: 'left', + width: this.width, + height: this.height, + fontSize: fontSize, + lineHeight: this.lineHeight, + fill: this.fill, + fontFamily: this.fontFamily, + fontWeight: this.fontWeight, + fontStyle: this.fontStyle, + text: text, + textAlign: this.textAlign, + splitByGrapheme: this.splitLongWords + }) + tmptext.setCoords(); + var lineHeights = 0; + for (var i = 0, len = tmptext._textLines.length; i < len; i++) { + var heightOfLine = tmptext.getHeightOfLine(i); + lineHeights += heightOfLine; + } + if (!this.autoResize || (lineHeights <= this.height && tmptext.width <= this.width) || fontSize <= 1.0) { + return {textbox: tmptext, height: lineHeights, width: tmptext.width} + } + if (lineHeights > this.height) { // we can do larger steps for height + fontSize = fontSize - Math.max(1.0, fontSize * .1) + } else { + fontSize = fontSize - Math.max(.25, fontSize * .025) + } + } + }, + + _render: function (ctx) { + var h = this.height, w = this.width; + + /* + var x = -this.width / 2, + y = -this.height / 2; + ctx.fillStyle = '#ccc'; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + w, y); + ctx.lineTo(x + w, y + h); + ctx.lineTo(x, y + h); + ctx.lineTo(x, y); + ctx.closePath(); + ctx.fill(); + */ + var { textbox, height, width } = this._internalTextbox(); + + ctx.save(); + if (this.verticalAlign === "top") { + ctx.translate(0, - (h - height) / 2); + } else if (this.verticalAlign === "bottom") { + ctx.translate(0, (h - height) / 2); + } + + // it is entirely unclear to me why overflow is always rendered centered, so we manually readjust + if (this.textAlign === "left" && width > w) { + ctx.translate(-(w - width) / 2, 0); + } else if (this.verticalAlign === "right" && width > w) { + ctx.translate((w - width) / 2, 0); + } + textbox._render(ctx); + ctx.restore(); + }, +}); +fabric.Textcontainer.fromObject = function (object, callback, forceAsync) { + return fabric.Object._fromObject('Textcontainer', object, callback, forceAsync); +}; fabric.Barcodearea = fabric.util.createClass(fabric.Rect, { type: 'barcodearea', @@ -150,9 +249,10 @@ var editor = { top += o.group.top + o.group.height / 2; left += o.group.left + o.group.width / 2; } + var col, bottom; if (o.type === "textarea") { - var col = (new fabric.Color(o.fill))._source; - var bottom = editor.pdf_viewport.height - o.height - top; + col = (new fabric.Color(o.fill))._source; + bottom = editor.pdf_viewport.height - o.height - top; if (o.downward) { bottom = editor.pdf_viewport.height - top; } @@ -176,6 +276,32 @@ var editor = { rotation: o.angle, align: o.textAlign, }); + } else if (o.type === "textcontainer") { + col = (new fabric.Color(o.fill))._source; + bottom = editor.pdf_viewport.height - o.height - top; + d.push({ + type: "textcontainer", + page: editor.pdf_page_number, + locale: $("#pdf-info-locale").val(), + left: editor._px2mm(left).toFixed(2), + bottom: editor._px2mm(bottom).toFixed(2), + fontsize: editor._px2pt(o.fontSize).toFixed(1), + lineheight: o.lineHeight, + color: col, + fontfamily: o.fontFamily, + bold: o.fontWeight === 'bold', + italic: o.fontStyle === 'italic', + width: editor._px2mm(o.width).toFixed(2), + height: editor._px2mm(o.height).toFixed(2), + content: o.content, + text: o.text, + text_i18n: o.text_i18n || {}, + rotation: o.angle, + align: o.textAlign || 'left', + verticalalign: o.verticalAlign || 'middle', + autoresize: o.autoResize || false, + splitlongwords: o.splitLongWords || false, + }); } else if (o.type === "imagearea") { d.push({ type: "imagearea", @@ -239,6 +365,36 @@ var editor = { o = editor._add_poweredby(d.content); o.content = d.content; o.scaleToHeight(editor._mm2px(d.size)); + } else if (d.type === "textcontainer") { + o = editor._add_textcontainer(); + o.set('fill', 'rgb(' + d.color[0] + ',' + d.color[1] + ',' + d.color[2] + ')'); + o.set('fontSize', editor._pt2px(d.fontsize)); + o.set('lineHeight', d.lineheight || 1); + o.set('fontFamily', d.fontfamily); + o.set('fontWeight', d.bold ? 'bold' : 'normal'); + o.set('fontStyle', d.italic ? 'italic' : 'normal'); + o.content = d.content; + o.set('textAlign', d.align); + o.set('verticalAlign', d.verticalalign); + o.set('autoResize', d.autoresize); + o.set('splitLongWords', d.splitlongwords); + if (d.rotation) { + o.rotate(d.rotation); + } + if (d.content === "other") { + o.set('text', d.text); + } else if (d.content === "other_i18n") { + o.text_i18n = d.text_i18n + o.set('text', d.text_i18n[Object.keys(d.text_i18n)[0]]); + } else if (d.content) { + o.set('text', editor._get_text_sample(d.content)); + } + o.set('width', editor._mm2px(d.width)); // needs to be after setText + o.set('height', editor._mm2px(d.height)); // needs to be after setText + if (d.locale) { + // The data format allows to set the locale per text field but we currently only expose a global field + $("#pdf-info-locale").val(d.locale); + } } else if (d.type === "textarea" || o.type === "text") { o = editor._add_text(); o.set('fill', 'rgb(' + d.color[0] + ',' + d.color[1] + ',' + d.color[2] + ')'); @@ -455,6 +611,11 @@ var editor = { }, _update_toolbox_values: function () { + $("#version-notice").toggle( + editor._other_page_objects.some((o) => o.type === "textcontainer") || + editor.fabric.getObjects().some((o) => o.type === "textcontainer") + ); + var o = editor.fabric.getActiveObject(); if (!o) { return; @@ -484,6 +645,35 @@ var editor = { } else if (o.type === "poweredby") { $("#toolbox-squaresize").val(editor._px2mm(o.height * o.scaleY).toFixed(2)); $("#toolbox-poweredby-style").val(o.content); + } else if (o.type === "textcontainer") { + var col = (new fabric.Color(o.fill))._source; + $("#toolbox-col").val("#" + ((1 << 24) + (col[0] << 16) + (col[1] << 8) + col[2]).toString(16).slice(1)); + $("#toolbox-fontsize").val(editor._px2pt(o.fontSize).toFixed(1)); + $("#toolbox-lineheight").val(o.lineHeight || 1); + $("#toolbox-fontfamily").val(o.fontFamily); + $("#toolbox").find("button[data-action=bold]").toggleClass('active', o.fontWeight === 'bold'); + $("#toolbox").find("button[data-action=italic]").toggleClass('active', o.fontStyle === 'italic'); + $("#toolbox").find("button[data-action=autoresize]").toggleClass('active', o.autoResize || false) + $("#toolbox").find("button[data-action=splitlongwords]").toggleClass('active', o.splitLongWords || false) + $("#toolbox").find("button[data-action=left]").toggleClass('active', o.textAlign === 'left' || !o.textAlign); + $("#toolbox").find("button[data-action=center]").toggleClass('active', o.textAlign === 'center'); + $("#toolbox").find("button[data-action=right]").toggleClass('active', o.textAlign === 'right'); + $("#toolbox").find("button[data-action=top]").toggleClass('active', o.verticalAlign === 'top' || !o.verticalAlign); + $("#toolbox").find("button[data-action=middle]").toggleClass('active', o.verticalAlign === 'middle'); + $("#toolbox").find("button[data-action=bottom]").toggleClass('active', o.verticalAlign === 'bottom'); + + if (o.scaleY !== 1 || o.scaleX !== 1) { + o.set({ + height: o.height * o.scaleY, + width: o.width * o.scaleX, + scaleX: 1, + scaleY: 1 + }); + } + + $("#toolbox-height").val(editor._px2mm(o.height).toFixed(2)); + $("#toolbox-width").val(editor._px2mm(o.width).toFixed(2)); + $("#toolbox-textrotation").val((o.angle || 0.0).toFixed(1)); } else if (o.type === "text" || o.type === "textarea") { var col = (new fabric.Color(o.fill))._source; $("#toolbox-col").val("#" + ((1 << 24) + (col[0] << 16) + (col[1] << 8) + col[2]).toString(16).slice(1)); @@ -500,7 +690,7 @@ var editor = { $("#toolbox-textrotation").val((o.angle || 0.0).toFixed(1)); } - if (o.type === "textarea" || o.type === "barcodearea") { + if (o.type === "textarea" || o.type === "barcodearea" || o.type === "textcontainer") { if (!o.content && o.type == "barcodearea") { o.content = "secret"; } @@ -598,6 +788,50 @@ var editor = { editor.fabric.discardActiveObject(); editor.fabric.setActiveObject(newo); } + } else if (o.type === "textcontainer") { + o.set('fill', $("#toolbox-col").val()); + o.set('fontSize', editor._pt2px($("#toolbox-fontsize").val())); + o.set('lineHeight', $("#toolbox-lineheight").val() || 1); + o.set('fontFamily', $("#toolbox-fontfamily").val()); + o.set('fontWeight', $("#toolbox").find("button[data-action=bold]").is('.active') ? 'bold' : 'normal'); + o.set('fontStyle', $("#toolbox").find("button[data-action=italic]").is('.active') ? 'italic' : 'normal'); + var align = $("#toolbox-align").find(".active").attr("data-action"); + if (align) { + o.set('textAlign', align); + } + var verticalAlign = $("#toolbox-verticalalign").find(".active").attr("data-action"); + if (verticalAlign) { + o.set('verticalAlign', verticalAlign); + } + o.set('autoResize', $("#toolbox").find("button[data-action=autoresize]").is('.active')); + o.set('splitLongWords', $("#toolbox").find("button[data-action=splitlongwords]").is('.active')); + // todo: verticalalign + o.rotate(parseFloat($("#toolbox-textrotation").val())); + $("#toolbox-content-other").toggle($("#toolbox-content").val() === "other"); + $("#toolbox-content-other-i18n").toggle($("#toolbox-content").val() === "other_i18n"); + $("#toolbox-content-other-help").toggle($("#toolbox-content").val() === "other" || $("#toolbox-content").val() === "other_i18n"); + o.content = $("#toolbox-content").val(); + if ($("#toolbox-content").val() === "other") { + if (e.target.id === "toolbox-content") { + // user used dropdown to switch content-type, update value with value from i18n textarea + $("#toolbox-content-other").val($("#toolbox-content-other-i18n textarea").val()); + } + o.set('text', $("#toolbox-content-other").val()); + } else if ($("#toolbox-content").val() === "other_i18n") { + if (e.target.id === "toolbox-content") { + // user used dropdown to switch content-type, update value with value from "other" textarea + $("#toolbox-content-other-i18n textarea").val($("#toolbox-content-other").val()); + } + o.text_i18n = {} + $("#toolbox-content-other-i18n textarea").each(function () { + o.text_i18n[$(this).attr("lang")] = $(this).val(); + }); + o.set('text', $("#toolbox-content-other-i18n textarea").first().val()); + } else { + o.set('text', editor._get_text_sample($("#toolbox-content").val())); + } + o.set('width', editor._mm2px($("#toolbox-width").val())); + o.set('height', editor._mm2px($("#toolbox-height").val())); } else if (o.type === "textarea" || o.type === "text") { o.set('fill', $("#toolbox-col").val()); o.set('fontSize', editor._pt2px($("#toolbox-fontsize").val())); @@ -658,7 +892,9 @@ var editor = { var o = selected[0]; $("#toolbox").attr("data-type", o.type); if (o.type === "textarea" || o.type === "text") { - $("#toolbox-heading").text(gettext("Text object")); + $("#toolbox-heading").text(gettext("Text object (deprecated)")); + } else if (o.type === "textarea" || o.type === "text" || o.type === "textcontainer") { + $("#toolbox-heading").text(gettext("Text box")); } else if (o.type === "barcodearea") { $("#toolbox-heading").text(gettext("Barcode area")); } else if (o.type === "imagearea") { @@ -727,6 +963,44 @@ var editor = { return rect; }, + _add_textcontainer: function () { + var rect = new fabric.Textcontainer(editor._get_text_sample('event_name'), { + left: 100, + top: 100, + width: 300, + height: 29, + lockRotation: false, + fill: '#000', + content: 'event_name', + text: editor._get_text_sample('event_name'), + fontFamily: 'Open Sans', + fontStyle: 'normal', + lineHeight: 1, + editable: false, + fontSize: editor._pt2px(13), + verticalAlign: 'middle', + textAlign: 'left', + splitLongWords: true, + autoResize: true, + lockScalingFlip: true, + }); + rect.setControlsVisibility({ + 'tr': true, + 'tl': true, + 'mt': true, + 'br': true, + 'bl': true, + 'mb': true, + 'mr': true, + 'ml': true, + 'mtr': true + }); + editor.fabric.add(rect); + editor._create_savepoint(); + $("#version-notice").show(); + return rect; + }, + _add_imagearea: function () { var rect = new fabric.Imagearea({ left: 100, @@ -1063,6 +1337,7 @@ var editor = { editor._load_pdf(); $("#editor-add-qrcode, #editor-add-qrcode-lead, #editor-add-qrcode-other").click(editor._add_qrcode); $("#editor-add-image").click(editor._add_imagearea); + $("#editor-add-textcontainer").click(editor._add_textcontainer); $("#editor-add-text").click(editor._add_text); $("#editor-add-poweredby").click(function() {editor._add_poweredby("dark")}); editor.$cva.get(0).tabIndex = 1000; @@ -1144,6 +1419,7 @@ var editor = { editor._update_save_button(); }); $("#pdf-info-width, #pdf-info-height").bind('change input', editor._paper_size_warning); + editor._update_toolbox_values(); $.getJSON($("#schema-url").text(), function (data) { editor.schema = data; diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 07d8349b85..a58dac8128 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -298,29 +298,45 @@ var form_handlers = function (el) { } } }).not(".no-contrast").on('changeColor create', function (e) { + if (e.type == 'changeColor' && !e.value) { + return; + } var rgb = $(this).colorpicker('color').toRGB(); var c = contrast([255,255,255], [rgb.r, rgb.g, rgb.b]); var mark = 'times'; - if ($(this).parent().find(".contrast-state").length === 0) { + var $icon = $(this).parent().find(".contrast-icon"); + if ($icon.length === 0 && $(this).parent().find(".contrast-state").length === 0) { $(this).parent().append("
"); } var $note = $(this).parent().find(".contrast-state"); if ($(this).val() === "") { $note.remove(); } + var icon, text, cls; if (c > 7) { - $note.html("") - .append(gettext('Your color has great contrast and is very easy to read!')); - $note.addClass("text-success").removeClass("text-warning").removeClass("text-danger"); + icon = "fa-check-circle"; + text = gettext('Your color has great contrast and is very easy to read!'); + cls = "text-success"; } else if (c > 2.5) { - $note.html("") - .append(gettext('Your color has decent contrast and is probably good-enough to read!')); - $note.removeClass("text-success").removeClass("text-warning").removeClass("text-danger"); + icon = "fa-info-circle"; + text = gettext('Your color has decent contrast and is probably good-enough to read!'); + cls = ""; } else { - $note.html("") - .append(gettext('Your color has bad contrast for text on white background, please choose a darker ' + - 'shade.')); - $note.addClass("text-danger").removeClass("text-success").removeClass("text-warning"); + icon = "fa-warning"; + text = gettext('Your color has bad contrast for text on white background, please choose a darker shade.'); + cls = "text-danger"; + } + if ($icon.length === 0) { + $note.html("") + .append(text); + $note.removeClass("text-success").removeClass("text-danger").addClass(cls); + } else { + $icon.html("") + $icon.attr("title", text); + $icon.tooltip('destroy'); + window.setTimeout(function() { + $icon.tooltip({"title": text}); + }, 250); } }); diff --git a/src/pretix/static/pretixcontrol/scss/pdfeditor.css b/src/pretix/static/pretixcontrol/scss/pdfeditor.css index c4a84e5e65..6615b74b83 100644 --- a/src/pretix/static/pretixcontrol/scss/pdfeditor.css +++ b/src/pretix/static/pretixcontrol/scss/pdfeditor.css @@ -19,6 +19,8 @@ body { #toolbox .poweredby, #toolbox[data-type] .pdf-info, #toolbox .text, +#toolbox .textarea, +#toolbox .textcontainer, #toolbox .imagecontent, #toolbox .object-buttons { display: none; @@ -30,10 +32,23 @@ body { #toolbox[data-type=imagearea] .imagecontent, #toolbox[data-type=poweredby] .poweredby, #toolbox[data-type=text] .text, +#toolbox[data-type=text] .textarea, +#toolbox[data-type=textcontainer] .text, +#toolbox[data-type=textcontainer] .textcontainer, +#toolbox[data-type=textcontainer] .rectsize, #toolbox[data-type=textarea] .text, +#toolbox[data-type=textarea] .textarea, #toolbox[data-type] .object-buttons { display: block; } +#toolbox[data-type=text] .btn-group-justified > .btn-group.text, +#toolbox[data-type=text] .btn-group-justified > .btn-group.textarea, +#toolbox[data-type=textcontainer] .btn-group-justified > .btn-group.text, +#toolbox[data-type=textcontainer] .btn-group-justified > .btn-group.textcontainer, +#toolbox[data-type=textarea] .btn-group-justified > .btn-group.text, +#toolbox[data-type=textarea] .btn-group-justified > .btn-group.textarea { + display: table-cell; +} #loading-container { position: absolute; top: 0;