changeset 2229:6cce66a6ceb5

merged pdf-export into default
author Markus Kottlaender <markus@intevation.de>
date Wed, 13 Feb 2019 08:00:26 +0100
parents 25f73251a6ac (current diff) 9b15293d028c (diff)
children 4374d942b23d
files client/src/components/Systemconfiguration.vue
diffstat 8 files changed, 913 insertions(+), 416 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/assets/application.scss	Tue Feb 12 23:28:01 2019 +0100
+++ b/client/src/assets/application.scss	Wed Feb 13 08:00:26 2019 +0100
@@ -99,16 +99,13 @@
 }
 
 .popup {
-  position: absolute;
-  top: 40%;
-  left: 50%;
-  margin-left: -150px;
   width: 300px;
   max-width: 300px;
+  @extend %fully-centered;
 }
 
 .popup.show {
-  margin: 0.5rem 0 0 -150px;
+  margin: 0.5rem 0 0 0;
   max-height: 999px;
 }
 
@@ -133,6 +130,14 @@
   font-weight: bold;
 }
 
+.list-fade-enter-active, .list-fade-leave-active {
+  transition: transform .3s;
+}
+.list-fade-enter, .list-fade-leave-to {
+  opacity: 0;
+  transform: translateY(20px);
+}
+
 .pointer {
   cursor: pointer;
 }
--- a/client/src/components/Pdftool.vue	Tue Feb 12 23:28:01 2019 +0100
+++ b/client/src/components/Pdftool.vue	Wed Feb 13 08:00:26 2019 +0100
@@ -21,19 +21,15 @@
           v-model="form.template"
           class="form-control d-block mb-2 w-100 font-weight-bold"
         >
-          <option :value="null"><translate>Chose preset</translate></option>
           <option
-            v-for="template in templates"
-            :value="template.name"
+            v-for="template in pdfTemplates"
+            :value="template"
             :key="template.name"
           >
             <translate>{{ template.name }}</translate>
           </option>
         </select>
-        <hr class="mb-1" />
-        <small class="text-muted"><translate>Format</translate></small>
         <select
-          @change="compareFormWithTemplates"
           v-model="form.format"
           class="form-control form-control-sm d-block mb-2 w-100"
         >
@@ -42,26 +38,22 @@
         </select>
         <div class="d-flex">
           <div class="flex-fill mr-2">
-            <small class="text-muted"><translate>Resolution</translate></small>
             <select
-              @change="compareFormWithTemplates"
               v-model="form.resolution"
               class="form-control form-control-sm mb-2 d-block w-100"
             >
-              <option value="80">80 dpi</option>
-              <option value="120">120 dpi</option>
-              <option value="200">200 dpi</option>
+              <option value="80"><translate>80 dpi</translate></option>
+              <option value="120"><translate>120 dpi</translate></option>
+              <option value="200"><translate>200 dpi</translate></option>
             </select>
           </div>
           <div class="flex-fill ml-2">
-            <small class="text-muted"><translate>Size</translate></small>
             <select
-              @change="compareFormWithTemplates"
               v-model="form.paperSize"
               class="form-control form-control-sm mb-2 d-block w-100"
             >
-              <option value="a3"><translate>ISO A3</translate></option>
-              <option value="a4"><translate>ISO A4</translate></option>
+              <option value="a4"><translate>A4</translate></option>
+              <option value="a3"><translate>A3</translate></option>
             </select>
           </div>
         </div>
@@ -137,32 +129,19 @@
         format: "landscape",
         paperSize: "a4",
         downloadType: "download",
-        resolution: "120"
+        resolution: "80"
       },
-      templates: [
-        {
-          name: "Template 1",
-          properties: {
-            format: "landscape",
-            resolution: "80",
-            paperSize: "a4"
-          }
-        },
-        {
-          name: "Template 2",
-          properties: {
-            format: "portrait",
-            resolution: "120",
-            paperSize: "a3"
-          }
-        }
-      ],
+      pdf: {
+        doc: null,
+        width: null,
+        height: null
+      },
       logoImageForPDF: null, // a HTMLImageElement instance
       readyToGenerate: true // if the user is allowed to press the button
     };
   },
   computed: {
-    ...mapState("application", ["showPdfTool", "logoForPDF"]),
+    ...mapState("application", ["showPdfTool", "logoForPDF", "pdfTemplates"]),
     ...mapState("bottlenecks", ["selectedBottleneck", "selectedSurvey"]),
     ...mapState("map", ["openLayersMap", "isolinesLegendImgDataURL"]),
     ...mapGetters("map", ["getLayerByName"]),
@@ -172,28 +151,15 @@
     // When a template is chosen from the dropdown, its propoerties are
     // applied to the rest of the form.
     applyTemplateToForm() {
-      let template = this.templates.find(t => t.name === this.form.template);
-      if (template) {
-        this.form.format = template.properties.format;
-        this.form.paperSize = template.properties.paperSize;
-        this.form.resolution = template.properties.resolution;
+      if (this.form.template) {
+        this.form.format = this.form.template.properties.format;
+        this.form.paperSize = this.form.template.properties.paperSize;
+        this.form.resolution = this.form.template.properties.resolution;
       }
     },
-    // If there's a template that matches all the form values, this template
-    // will be set in the dropdown.
-    compareFormWithTemplates() {
-      this.form.template = null;
-      this.templates.forEach(t => {
-        if (
-          this.form.format === t.properties.format &&
-          this.form.paperSize === t.properties.paperSize &&
-          this.form.resolution === t.properties.resolution
-        ) {
-          this.form.template = t.name;
-        }
-      });
-    },
     download() {
+      let template = this.form.template;
+
       // disable button while working on it
       this.readyToGenerate = false;
 
@@ -203,16 +169,15 @@
         this.form.format,
         this.form.resolution
       );
-      var width, height;
 
       if (this.form.format !== "portrait") {
         // landscape, default
-        width = paperSizes[this.form.paperSize][0];
-        height = paperSizes[this.form.paperSize][1];
+        this.pdf.width = paperSizes[this.form.paperSize][0];
+        this.pdf.height = paperSizes[this.form.paperSize][1];
       } else {
         // switch width and height
-        width = paperSizes[this.form.paperSize][1];
-        height = paperSizes[this.form.paperSize][0];
+        this.pdf.width = paperSizes[this.form.paperSize][1];
+        this.pdf.height = paperSizes[this.form.paperSize][0];
       }
 
       // FUTURE: consider margins
@@ -221,8 +186,8 @@
       var pixelsPerMapMillimeter = this.form.resolution / 25.4;
       var mapSizeForPrint = [
         // in pixel
-        Math.round(width * pixelsPerMapMillimeter),
-        Math.round(height * pixelsPerMapMillimeter)
+        Math.round(this.pdf.width * pixelsPerMapMillimeter),
+        Math.round(this.pdf.height * pixelsPerMapMillimeter)
       ];
 
       // generate PDF and open it
@@ -240,12 +205,9 @@
       // extent should fit.
       var mapExtent = map.getView().calculateExtent(mapSize);
 
-      var pdf = new jsPDF(this.form.format, "mm", this.form.paperSize);
-      var northarrowSize = 3;
-      var self = this;
-
+      this.pdf.doc = new jsPDF(this.form.format, "mm", this.form.paperSize);
       // set a callback for after the next complete rendering of the map
-      map.once("rendercomplete", function(event) {
+      map.once("rendercomplete", event => {
         let canvas = event.context.canvas;
 
         // because we are using Web Mercator, a pixel represents
@@ -265,70 +227,238 @@
         console.log("scaleNominator = ", scaleNominator);
 
         var data = canvas.toDataURL("image/jpeg");
-        pdf.addImage(data, "JPEG", 0, 0, width, height);
-        self.addScaleBar(pdf, width, height, scaleNominator);
-        self.addNorthArrow(pdf, 15, 9, northarrowSize);
-        self.addPageInfo(pdf);
-        self.addAboutBox(pdf, width, height);
+        this.pdf.doc.addImage(
+          data,
+          "JPEG",
+          0,
+          0,
+          this.pdf.width,
+          this.pdf.height
+        );
 
-        if (self.getLayerByName("Bottleneck isolines").isVisible) {
-          self.addBottleneckInfo(pdf, 15.5, width, height);
-          self.addLegend(pdf, 16.5, width, height);
+        if (template) {
+          let defaultFontSize = 10,
+            defaultRounding = 0,
+            defaultTextColor = "black",
+            defaultBgColor = "white",
+            defaultPadding = 3,
+            defaultOffset = { x: 0, y: 0 };
+          template.elements.forEach(e => {
+            switch (e.type) {
+              case "text": {
+                this.addText(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.width,
+                  e.rounding || defaultRounding,
+                  e.fontSize || defaultFontSize,
+                  e.color || defaultTextColor,
+                  e.text
+                );
+                break;
+              }
+              case "box": {
+                this.addBox(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.width,
+                  e.height,
+                  e.rounding || defaultRounding,
+                  e.color || defaultBgColor
+                );
+                break;
+              }
+              case "textbox": {
+                this.addTextBox(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.width,
+                  e.height,
+                  e.rounding || defaultRounding,
+                  e.padding || defaultPadding,
+                  e.fontSize || defaultFontSize,
+                  e.color || defaultTextColor,
+                  e.background || defaultBgColor,
+                  e.text
+                );
+                break;
+              }
+              case "image": {
+                this.addImage(
+                  e.url,
+                  e.format,
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.width,
+                  e.height
+                );
+                break;
+              }
+              case "bottleneck": {
+                this.addBottleneckInfo(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.rounding || defaultRounding,
+                  e.color || defaultTextColor
+                );
+                break;
+              }
+              case "legend": {
+                this.addLegend(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.rounding || defaultRounding
+                );
+                break;
+              }
+              case "scalebar": {
+                this.addScaleBar(
+                  scaleNominator,
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.rounding || defaultRounding
+                );
+                break;
+              }
+              case "northarrow": {
+                this.addNorthArrow(
+                  e.position,
+                  e.offset || defaultOffset,
+                  e.size
+                );
+                break;
+              }
+            }
+          });
+
+          this.pdf.doc.save("map.pdf");
         }
-
-        pdf.save("map.pdf");
         // reset to original size
         map.setSize(mapSize);
         map.getView().fit(mapExtent, { size: mapSize });
 
         // as we are done: re-enable button
-        self.readyToGenerate = true;
+        this.readyToGenerate = true;
       });
 
       // trigger rendering
-      this.prepareRendering(function() {
-        map.setSize(mapSizeForPrint);
-        map.getView().fit(mapExtent, { size: mapSizeForPrint });
-
-        /*
-      let a = document.createElement("a");
-      a.href = src; // need the generated PDF in here (as dataURL?)
-
-      if (this.form.downloadType === "download")
-        a.download = src.substr(src.lastIndexOf("/") + 1);
-      else a.target = "_blank";
-
-      document.body.appendChild(a);
-      a.click();
-      document.body.removeChild(a);
-      */
-      });
+      map.setSize(mapSizeForPrint);
+      map.getView().fit(mapExtent, { size: mapSizeForPrint });
     },
-    prepareRendering(callback) {
-      // call callback() once the preparations are done
-      this.logoImageForPDF = new Image();
-
-      this.logoImageForPDF.onload = function() {
-        callback();
-      };
-
-      if (this.logoForPDF) {
-        this.logoImageForPDF.src = this.logoForPDF;
-      } else {
-        this.logoImageForPDF.src = "/img/gemma-logo-for-pdf.png";
-      }
-    },
-    addRoundedBox(doc, x, y, w, h) {
+    addRoundedBox(x, y, w, h, color, rounding) {
       // draws a rounded background box at (x,y) width x height
       // using jsPDF units
-      doc.setDrawColor(255, 255, 255);
-      doc.setFillColor(255, 255, 255);
-      doc.roundedRect(x, y, w, h, 3, 3, "FD");
+      this.pdf.doc.setDrawColor(color);
+      this.pdf.doc.setFillColor(color);
+      this.pdf.doc.roundedRect(x, y, w, h, rounding, rounding, "FD");
+    },
+    // add some text at specific coordinates and determine how many wrolds in single line
+    addText(position, offset, width, rounding, fontSize, color, text) {
+      text = this.replacePlaceholders(text);
+
+      // split the incoming string to an array, each element is a string of
+      // words in a single line
+      this.pdf.doc.setTextColor(color);
+      this.pdf.doc.setFontSize(fontSize);
+      var textLines = this.pdf.doc.splitTextToSize(text, width);
+
+      // x/y defaults to offset for topleft corner (normal x/y coordinates)
+      let x = offset.x;
+      let y = offset.y;
+
+      // if position is on the right, x needs to be calculate with pdf width and
+      // the size of the element
+      if (["topright", "bottomright"].indexOf(position) !== -1) {
+        x = this.pdf.width - offset.x - width;
+      }
+      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+        y = this.pdf.height - offset.y - this.getTextHeight(textLines.length);
+      }
+
+      this.pdf.doc.text(textLines, x, y, { baseline: "hanging" });
+    },
+    addBox(position, offset, width, height, rounding, color) {
+      // x/y defaults to offset for topleft corner (normal x/y coordinates)
+      let x = offset.x;
+      let y = offset.y;
+
+      // if position is on the right, x needs to be calculate with pdf width and
+      // the size of the element
+      if (["topright", "bottomright"].indexOf(position) !== -1) {
+        x = this.pdf.width - offset.x - width;
+      }
+      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+        y = this.pdf.height - offset.y - height;
+      }
+
+      this.addRoundedBox(x, y, width, height, color, rounding);
     },
-    addScaleBar(doc, docWidth, docHeight, scaleNominator) {
+    // add some text at specific coordinates with a background box
+    addTextBox(
+      position,
+      offset,
+      width,
+      height,
+      rounding,
+      padding,
+      fontSize,
+      color,
+      background,
+      text
+    ) {
+      if (!width) {
+        width = this.pdf.width - offset.x;
+      }
+      let textWidth = width - 2 * padding;
+      if (!height) {
+        this.pdf.doc.setFontSize(fontSize);
+        text = this.replacePlaceholders(text);
+        let textLines = this.pdf.doc.splitTextToSize(text, textWidth);
+        height = this.getTextHeight(textLines.length) + 2 * padding;
+      }
+
+      this.addBox(position, offset, width, height, rounding, background);
+      this.addText(
+        position,
+        { x: offset.x + padding, y: offset.y + padding },
+        textWidth,
+        rounding,
+        fontSize,
+        color,
+        text
+      );
+    },
+    addImage(url, format, position, offset, width, height) {
+      // x/y defaults to offset for topleft corner (normal x/y coordinates)
+      let x = offset.x;
+      let y = offset.y;
+
+      // if position is on the right, x needs to be calculate with pdf width and
+      // the size of the element
+      if (["topright", "bottomright"].indexOf(position) !== -1) {
+        x = this.pdf.width - offset.x - width;
+      }
+      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+        y = this.pdf.height - offset.y - height;
+      }
+
+      let image = new Image();
+      if (url) {
+        image.src = url;
+      } else {
+        if (this.logoForPDF) {
+          image.src = this.logoForPDF;
+        } else {
+          image.src = "/img/gemma-logo-for-pdf.png";
+        }
+      }
+
+      this.pdf.doc.addImage(image, x, y, width, height);
+    },
+    addScaleBar(scaleNominator, position, offset, rounding) {
       // scaleNominator is the x in 1:x of the map scale
 
-      // hardcode maximal width for now and place in lower right corner
+      // hardcode maximal width for now
       let maxWidth = 80; // in mm
 
       // reduce width until we'll find a nice number for printing
@@ -383,154 +513,234 @@
       }
 
       let size = (length * unitConversionFactor) / scaleNominator / 4;
+      let fullSize = size * 4;
 
-      let x = docWidth - (size * 4 + 8);
-      let y = docHeight - 6;
+      // x/y defaults to offset for topleft corner (normal x/y coordinates)
+      let x = offset.x;
+      let y = offset.y;
 
-      this.addRoundedBox(doc, x - 4, y - 4, size * 4 + 12, 10);
+      // if position is on the right, x needs to be calculate with pdf width and
+      // the size of the element
+      if (["topright", "bottomright"].indexOf(position) !== -1) {
+        x = this.pdf.width - offset.x - fullSize - 8;
+      }
+      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+        y = this.pdf.height - offset.y - 10;
+      }
+
+      // to give the outer white box 4mm padding
+      let scaleBarX = x + 4;
+      let scaleBarY = y + 5; // 5 because above the scalebar will be the numbers
+
+      // draw outer white box
+      this.addRoundedBox(x, y, fullSize + 8, 10, "white", rounding);
 
-      doc.setDrawColor(0, 0, 0);
-      doc.setFillColor(0, 0, 0);
-      doc.rect(x, y, size, 1, "FD");
-      doc.setFillColor(255, 255, 255);
-      doc.setDrawColor(0, 0, 0);
-      doc.rect(x + size, y, size, 1, "FD");
-      doc.setFillColor(0, 0, 0);
-      doc.setDrawColor(0, 0, 0);
-      doc.rect(x + size * 2, y, size * 2, 1, "FD");
-      doc.setFontSize(5);
-      doc.text(x, y + 3, "0");
+      // draw first part of scalebar
+      this.pdf.doc.setDrawColor(0, 0, 0);
+      this.pdf.doc.setFillColor(0, 0, 0);
+      this.pdf.doc.rect(scaleBarX, scaleBarY, size, 1, "FD");
+
+      // draw second part of scalebar
+      this.pdf.doc.setDrawColor(0, 0, 0);
+      this.pdf.doc.setFillColor(255, 255, 255);
+      this.pdf.doc.rect(scaleBarX + size, scaleBarY, size, 1, "FD");
+
+      // draw third part of scalebar
+      this.pdf.doc.setDrawColor(0, 0, 0);
+      this.pdf.doc.setFillColor(0, 0, 0);
+      this.pdf.doc.rect(scaleBarX + size * 2, scaleBarY, size * 2, 1, "FD");
+
+      // draw numeric labels above scalebar
+      this.pdf.doc.setTextColor("black");
+      this.pdf.doc.setFontSize(5);
+      this.pdf.doc.text(scaleBarX, scaleBarY - 1, "0");
       // /4 and could give 2.5. We still round, because of floating point arith
-      doc.text(
-        x + size,
-        y + 3,
+      this.pdf.doc.text(
+        scaleBarX + size - 1,
+        scaleBarY - 1,
         (Math.round((length * 10) / 4) / 10).toString()
       );
-      doc.text(x + size * 2, y + 3, Math.round(length / 2).toString());
-      doc.text(x + size * 4, y + 3, Math.round(length).toString() + " " + unit);
+      this.pdf.doc.text(
+        scaleBarX + size * 2 - 2,
+        scaleBarY - 1,
+        Math.round(length / 2).toString()
+      );
+      this.pdf.doc.text(
+        scaleBarX + size * 4 - 4,
+        scaleBarY - 1,
+        Math.round(length).toString() + " " + unit
+      );
     },
+    addNorthArrow(position, offset, size) {
+      // TODO: fix positioning
+      // x/y defaults to offset for topleft corner (normal x/y coordinates)
+      let x1 = offset.x;
+      let y1 = offset.y;
 
-    addNorthArrow(doc, x1, y1, size) {
+      // if position is on the right, x needs to be calculate with pdf width and
+      // the size of the element
+      if (["topright", "bottomright"].indexOf(position) !== -1) {
+        x1 = this.pdf.width - offset.x - size;
+      }
+      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+        y1 = this.pdf.height - offset.y - size;
+      }
+
       var y2 = y1 + size * 3;
       var x3 = x1 - size * 2;
       var y3 = y1 + size * 5;
       var x4 = x1 + size * 2;
-      //white triangle
-      doc.setFillColor(255, 255, 255);
-      doc.setDrawColor(255, 255, 255);
-      doc.triangle(x3 - 0.8, y3 + 1.2, x1, y1 - 1.2, x1, y2 + 0.6, "F");
-      doc.triangle(x1, y1 - 1.2, x1, y2 + 0.6, x4 + 0.8, y3 + 1.2, "F");
-      //north arrow
-      doc.setDrawColor(0, 0, 0);
-      doc.setFillColor(255, 255, 255);
-      doc.triangle(x3, y3, x1, y1, x1, y2, "FD");
-      doc.setFillColor(0, 0, 0);
-      doc.triangle(x1, y1, x1, y2, x4, y3, "FD");
-      doc.setFontSize(size * 3.1);
-      doc.setTextColor(255, 255, 255);
-      doc.setFontStyle("bold");
-      doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N");
-      doc.setFontSize(size * 3);
-      doc.setTextColor(0, 0, 0);
-      doc.setFontStyle("normal");
-      doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N");
+      // white triangle
+      this.pdf.doc.setFillColor(255, 255, 255);
+      this.pdf.doc.setDrawColor(255, 255, 255);
+      this.pdf.doc.triangle(
+        x3 - 0.8,
+        y3 + 1.2,
+        x1,
+        y1 - 1.2,
+        x1,
+        y2 + 0.6,
+        "F"
+      );
+      this.pdf.doc.triangle(
+        x1,
+        y1 - 1.2,
+        x1,
+        y2 + 0.6,
+        x4 + 0.8,
+        y3 + 1.2,
+        "F"
+      );
+
+      // north arrow
+      this.pdf.doc.setDrawColor(0, 0, 0);
+      this.pdf.doc.setFillColor(255, 255, 255);
+      this.pdf.doc.triangle(x3, y3, x1, y1, x1, y2, "FD");
+      this.pdf.doc.setFillColor(0, 0, 0);
+      this.pdf.doc.triangle(x1, y1, x1, y2, x4, y3, "FD");
+      this.pdf.doc.setFontSize(size * 3.1);
+      this.pdf.doc.setTextColor(255, 255, 255);
+      this.pdf.doc.setFontStyle("bold");
+      this.pdf.doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N");
+      this.pdf.doc.setFontSize(size * 3);
+      this.pdf.doc.setTextColor(0, 0, 0);
+      this.pdf.doc.setFontStyle("normal");
+      this.pdf.doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N");
     },
-    // add some text at specific coordinates and determine how many wrolds in single line
-    addText(doc, postitionX, positionY, size, color, lineWidth, text) {
-      // split the incoming string to an array, each element is a string of words in a single line
-      var textLines = doc.splitTextToSize(text, lineWidth);
-      doc.setTextColor(color);
-      doc.setFontSize(size);
-      doc.text(postitionX, positionY, textLines);
-    },
-    addPageInfo(doc) {
-      this.addRoundedBox(doc, 0, 0, 110, 8);
-      let str =
-        this.$gettext("Date of publication:") +
-        " " +
-        new Date().toLocaleString(locale2) +
-        " " +
-        this.$gettext("– generated by:") +
-        " " +
-        this.user;
-      this.addText(doc, 5, 5, 9, "black", 100, str);
-    },
-    addAboutBox(doc, docWidth, docHeight) {
-      let top = docHeight - 20;
-      this.addRoundedBox(doc, 0, top, 120, 20);
+    addLegend(position, offset, rounding) {
+      if (
+        this.selectedBottleneck &&
+        this.getLayerByName("Bottleneck isolines").isVisible
+      ) {
+        // transforming into an HTMLImageElement only to find out
+        // the width x height of the legend image
+        // FUTURE: find a better way to get the width and height
+        let legendImage = new Image();
+        legendImage.src = this.isolinesLegendImgDataURL;
+        let aspectRatio = legendImage.width / legendImage.height;
+        let width = 54;
+        let height = width / aspectRatio;
+        let padding = 2;
+
+        // x/y defaults to offset for topleft corner (normal x/y coordinates)
+        let x = offset.x;
+        let y = offset.y;
 
-      let logoImage = this.logoImageForPDF;
-      let aspectRatio = logoImage.width / logoImage.height;
-      doc.addImage(logoImage, "PNG", 5, docHeight - 19, 110, 110 / aspectRatio);
+        // if position is on the right, x needs to be calculate with pdf width and
+        // the size of the element
+        if (["topright", "bottomright"].indexOf(position) !== -1) {
+          x = this.pdf.width - offset.x - width;
+        }
+        if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+          y = this.pdf.height - offset.y - height;
+        }
 
-      let str =
-        "Dislaimer: Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua.";
-      this.addText(doc, 5, docHeight - 6, 8, "black", 115, str);
+        this.addRoundedBox(x, y, width, height, "white", rounding);
+        this.pdf.doc.addImage(
+          legendImage,
+          x + padding,
+          y + padding,
+          width - 2 * padding,
+          height - 2 * padding
+        );
+      }
     },
-    addLegend(doc, fromTop, docWidth) {
-      // transforming into an HTMLImageElement only to find out
-      // the width x height of the legend image
-      // FUTURE: find a better way to get the width and height
-      let legendImage = new Image();
-      legendImage.src = this.isolinesLegendImgDataURL;
-      let aspectRatio = legendImage.width / legendImage.height;
+    addBottleneckInfo(position, offset, rounding, color) {
+      if (
+        this.selectedBottleneck &&
+        this.getLayerByName("Bottleneck isolines").isVisible
+      ) {
+        let width = 54;
+        let height = 13;
+        let padding = 5;
+
+        // x/y defaults to offset for topleft corner (normal x/y coordinates)
+        let x = offset.x;
+        let y = offset.y;
 
-      this.addRoundedBox(doc, docWidth - 54, fromTop, 54, 50 / aspectRatio + 4);
-      doc.addImage(
-        legendImage,
-        docWidth - 52,
-        fromTop + 2,
-        50,
-        50 / aspectRatio
-      );
-    },
-    addBottleneckInfo(doc, height, docWidth) {
-      this.addRoundedBox(doc, docWidth - 54, 0, 54, height);
+        // if position is on the right, x needs to be calculate with pdf width and
+        // the size of the element
+        if (["topright", "bottomright"].indexOf(position) !== -1) {
+          x = this.pdf.width - offset.x - width;
+        }
+        if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
+          y = this.pdf.height - offset.y - height;
+        }
+
+        this.addRoundedBox(x, y, width, height, "white", rounding);
 
-      console.log("Fontlist =", doc.getFontList());
-      doc.setFont("times", "normal");
+        this.pdf.doc.setFont("times", "normal");
+        this.pdf.doc.setFontSize(9);
+        this.pdf.doc.setTextColor(color);
+
+        let str, w;
 
-      let name, w, str;
+        str = this.$gettext("Bottleneck") + ": ";
+        w = this.pdf.doc.getTextWidth(str);
+        this.pdf.doc.setFontStyle("italic");
+        this.pdf.doc.text(x + padding, y + padding, str);
+        str = this.selectedBottleneck;
+        this.pdf.doc.setFontStyle("bold");
+        this.pdf.doc.text(x + padding + w, y + padding, str);
 
-      doc.setFontStyle("italic");
-      name = this.$gettext("Bottleneck") + ": ";
-      w = doc.getTextWidth(name);
-      this.addText(doc, docWidth - 51, 4, 8, "black", 46, name);
-      doc.setFontStyle("bold");
-      str = this.selectedBottleneck;
-      this.addText(doc, docWidth - 51 + w, 4, 8, "black", 46, str);
-
-      let survey = this.selectedSurvey;
-
-      doc.setFontStyle("italic");
-      name = this.$gettext("Survey date") + ": ";
-      w = doc.getTextWidth(name);
-      this.addText(doc, docWidth - 51, 7.5, 8, "black", 46, name);
-      doc.setFontStyle("normal");
-      str = survey.date_info;
-      this.addText(doc, docWidth - 51 + w, 7.5, 8, "black", 46, str);
+        str = this.$gettext("Survey date") + ": ";
+        w = this.pdf.doc.getTextWidth(str);
+        this.pdf.doc.setFontStyle("italic");
+        this.pdf.doc.text(x + padding, y + padding + 3, str);
+        str = this.selectedSurvey.date_info;
+        this.pdf.doc.setFontStyle("normal");
+        this.pdf.doc.text(x + padding + w, y + padding + 3, str);
 
-      doc.setFontStyle("italic");
-      name = this.$gettext("Ref gauge") + ": ";
-      w = doc.getTextWidth(name);
-      this.addText(doc, docWidth - 51, 11, 8, "black", 46, name);
-      doc.setFontStyle("normal");
-      str = survey.gauge_objname;
-      this.addText(doc, docWidth - 51 + w, 11, 8, "black", 46, str);
-
-      doc.setFontStyle("italic");
-      name = this.$gettext("Depth relativ to") + ": ";
-      w = doc.getTextWidth(name);
-      this.addText(doc, docWidth - 51, 14.5, 8, "black", 46, name);
-      doc.setFontStyle("normal");
-      str = survey.depth_reference + " = ";
-      if (survey.hasOwnProperty("waterlevel_value")) {
-        str += survey.waterlevel_value + " cm";
-      } else {
-        str += "?";
+        str = this.$gettext("Ref gauge") + ": ";
+        w = this.pdf.doc.getTextWidth(str);
+        this.pdf.doc.setFontStyle("italic");
+        this.pdf.doc.text(x + padding, y + padding + 6, str);
+        str = this.selectedSurvey.gauge_objname;
+        this.pdf.doc.setFontStyle("normal");
+        this.pdf.doc.text(x + padding + w, y + padding + 6, str);
+      }
+    },
+    replacePlaceholders(text) {
+      if (text.includes("{date}")) {
+        text = text.replace("{date}", new Date().toLocaleString(locale2));
       }
-      this.addText(doc, docWidth - 51 + w, 14.5, 8, "black", 46, str);
+      if (text.includes("{user}")) {
+        text = text.replace("{user}", this.user);
+      }
+      return text;
+    },
+    getTextHeight(numberOfLines) {
+      return (
+        numberOfLines *
+        ((this.pdf.doc.getFontSize() * 25.4) / parseInt(this.form.resolution)) *
+        this.pdf.doc.getLineHeightFactor()
+      );
     }
+  },
+  mounted() {
+    this.$store.dispatch("application/loadPdfTemplates").then(() => {
+      this.form.template = this.pdfTemplates[0];
+    });
   }
 };
 </script>
--- a/client/src/components/Systemconfiguration.vue	Tue Feb 12 23:28:01 2019 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-<template>
-  <div class="d-flex flex-row">
-    <Spacer></Spacer>
-    <div class="card sysconfig mt-3 shadow-xs">
-      <h6
-        class="mb-0 py-2 px-3 border-bottom d-flex text-info align-items-center"
-      >
-        <font-awesome-icon icon="wrench" class="mr-2"></font-awesome-icon>
-        <translate class="headline">Systemconfiguration</translate>
-      </h6>
-      <div class="card-body config">
-        <section class="configsection">
-          <h4 class="card-title">
-            <translate>Bottleneck Areas stroke-color</translate>
-          </h4>
-          <compact-picker v-model="strokeColor" />
-        </section>
-        <section>
-          <h4 class="card-title">
-            <translate>Bottleneck Areas fill-color</translate>
-          </h4>
-          <chrome-picker v-model="fillColor" />
-        </section>
-        <div class="sendbutton">
-          <a @click.prevent="submit" class="btn btn-info text-white">
-            <translate>Send</translate>
-          </a>
-        </div>
-      </div>
-      <!-- card-body -->
-    </div>
-  </div>
-</template>
-
-<style scoped lang="scss">
-.config {
-  text-align: left;
-}
-
-.configsection {
-  margin-bottom: $large-offset;
-}
-
-.sendbutton {
-  position: absolute;
-  right: $offset;
-  bottom: $offset;
-}
-
-.inputs {
-  margin-left: auto;
-  margin-right: auto;
-}
-
-.sysconfig {
-  margin-right: $offset;
-  width: 100%;
-  height: 100%;
-}
-</style>
-
-<script>
-/* This is Free Software under GNU Affero General Public License v >= 3.0
- * without warranty, see README.md and license for details.
- *
- * SPDX-License-Identifier: AGPL-3.0-or-later
- * License-Filename: LICENSES/AGPL-3.0.txt
- *
- * Copyright (C) 2018 by via donau
- *   – Österreichische Wasserstraßen-Gesellschaft mbH
- * Software engineering by Intevation GmbH
- *
- * Author(s):
- * Thomas Junk <thomas.junk@intevation.de>
- * Bernhard Reiter <bernhard@intevation.de>
- */
-import { Chrome } from "vue-color";
-import { Compact } from "vue-color";
-
-import { HTTP } from "@/lib/http";
-import { displayError } from "@/lib/errors.js";
-import { mapState } from "vuex";
-
-export default {
-  name: "systemconfiguration",
-  data() {
-    return {
-      sent: false,
-      strokeColor: { r: 0, g: 0, b: 0, a: 1.0 },
-      fillColor: { r: 0, g: 0, b: 0, a: 1.0 },
-      currentConfig: null
-    };
-  },
-  components: {
-    "chrome-picker": Chrome,
-    "compact-picker": Compact,
-    Spacer: () => import("./Spacer")
-  },
-  computed: {
-    ...mapState("application", ["showSidebar"])
-  },
-  methods: {
-    submit() {
-      HTTP.put("/system/style/Bottlenecks/stroke", this.strokeColor.rgba, {
-        headers: {
-          "X-Gemma-Auth": localStorage.getItem("token"),
-          "Content-type": "application/json"
-        }
-      })
-        .then()
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-
-      HTTP.put("/system/style/Bottlenecks/fill", this.fillColor.rgba, {
-        headers: {
-          "X-Gemma-Auth": localStorage.getItem("token"),
-          "Content-type": "application/json"
-        }
-      })
-        .then()
-        .catch(error => {
-          const { status, data } = error.response;
-          displayError({
-            title: this.$gettext("Backend Error"),
-            message: `${status}: ${data.message || data}`
-          });
-        });
-    }
-  },
-  mounted() {
-    HTTP.get("/system/style/Bottlenecks/stroke", {
-      headers: {
-        "X-Gemma-Auth": localStorage.getItem("token"),
-        "Content-type": "application/json"
-      }
-    })
-      .then(response => {
-        this.strokeColor = response.data.colour;
-      })
-      .catch(error => {
-        const { status, data } = error.response;
-        displayError({
-          title: this.$gettext("Backend Error"),
-          message: `${status}: ${data.message || data}`
-        });
-      });
-
-    HTTP.get("/system/style/Bottlenecks/fill", {
-      headers: {
-        "X-Gemma-Auth": localStorage.getItem("token"),
-        "Content-type": "application/json"
-      }
-    })
-      .then(response => {
-        this.fillColor = response.data.colour;
-      })
-      .catch(error => {
-        const { status, data } = error.response;
-        displayError({
-          title: this.$gettext("Backend Error"),
-          message: `${status}: ${data.message || data}`
-        });
-      });
-  }
-};
-</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/systemconfiguration/PDFTemplates.vue	Wed Feb 13 08:00:26 2019 +0100
@@ -0,0 +1,142 @@
+<template>
+  <div class="d-flex flex-column mt-4">
+    <div class="d-flex flex-row justify-content-between">
+      <h5><translate>PDF-Templates</translate></h5>
+      <input
+        @change="upload"
+        id="uploadPDFTemplates"
+        ref="uploadPDFTemplates"
+        type="file"
+        style="visibility:hidden"
+      />
+      <button
+        class="btn btn-sm btn-info"
+        @click="$refs.uploadPDFTemplates.click()"
+      >
+        <font-awesome-icon icon="spinner" class="fa-spin" v-if="uploading" />
+        <font-awesome-icon icon="plus" v-else />
+      </button>
+    </div>
+    <div class="d-flex mt-1">
+      <table class="table table-sm">
+        <thead>
+          <tr>
+            <th>Name</th>
+            <th>Description</th>
+            <th>Date</th>
+            <th>Country</th>
+            <th></th>
+          </tr>
+        </thead>
+        <transition-group name="list-fade" tag="tbody">
+          <tr v-for="template in pdfTemplates" :key="template.name">
+            <td>{{ template.name }}</td>
+            <td>{{ template.description }}</td>
+            <td>{{ template.date }}</td>
+            <td></td>
+            <td class="text-right">
+              <button class="btn btn-sm btn-info mr-2">
+                <font-awesome-icon icon="download" />
+              </button>
+              <button
+                class="btn btn-sm btn-danger"
+                @click="
+                  showDeleteTemplatePrompt = true;
+                  templateToDelete = template;
+                "
+              >
+                <font-awesome-icon icon="trash" />
+              </button>
+            </td>
+          </tr>
+        </transition-group>
+      </table>
+    </div>
+
+    <div
+      :class="[
+        'box popup ui-element rounded bg-white',
+        { show: showDeleteTemplatePrompt }
+      ]"
+    >
+      <div>
+        <h6 class="mb-0 py-2 px-3 border-bottom d-flex align-items-center">
+          <font-awesome-icon icon="trash" class="mr-2"></font-awesome-icon>
+          <translate>Delete PDF Template</translate>
+          <font-awesome-icon
+            icon="times"
+            class="ml-auto text-muted"
+            @click="showDeleteTemplatePrompt = false"
+          ></font-awesome-icon>
+        </h6>
+        <div class="p-3 text-left">
+          <translate class="text-center d-block">
+            Do you really want to delete the following template:
+          </translate>
+          <h5 class="mt-3 text-center">{{ templateToDelete.name }}</h5>
+        </div>
+        <div
+          class="py-2 px-3 border-top d-flex align-items-center justify-content-between"
+        >
+          <button
+            class="btn btn-sm btn-warning"
+            @click="showDeleteTemplatePrompt = false"
+          >
+            no
+          </button>
+          <button class="btn btn-sm btn-info" @click="remove(templateToDelete)">
+            yes
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Markus Kottländer <markus@intevation.de>
+ */
+import { mapState } from "vuex";
+
+export default {
+  name: "pdftemplates",
+  data() {
+    return {
+      uploading: false,
+      templateToDelete: "",
+      showDeleteTemplatePrompt: false
+    };
+  },
+  computed: {
+    ...mapState("application", ["pdfTemplates"])
+  },
+  methods: {
+    upload() {
+      this.uploading = true;
+      this.$store
+        .dispatch(
+          "application/uploadPDFTemplates",
+          this.$refs.uploadPDFTemplates.files
+        )
+        .then(() => {
+          this.uploading = false;
+        });
+    },
+    remove(template) {
+      this.showDeleteTemplatePrompt = false;
+      this.$store.dispatch("application/removePDFTemplate", template);
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/systemconfiguration/Systemconfiguration.vue	Wed Feb 13 08:00:26 2019 +0100
@@ -0,0 +1,159 @@
+<template>
+  <div class="d-flex flex-row">
+    <Spacer></Spacer>
+    <div class="card sysconfig mt-3 shadow-xs">
+      <h6
+        class="mb-0 py-2 px-3 border-bottom d-flex text-info align-items-center"
+      >
+        <font-awesome-icon icon="wrench" class="mr-2"></font-awesome-icon>
+        <translate class="headline">Systemconfiguration</translate>
+      </h6>
+      <div class="card-body text-left">
+        <h5 class="border-bottom pb-3 mb-3">
+          <translate>Color settings</translate>
+        </h5>
+        <div class="d-flex">
+          <div>
+            <h6 class="card-title">
+              <translate>Bottleneck Areas fill-color</translate>
+            </h6>
+            <chrome-picker v-model="fillColor" />
+          </div>
+          <div class="ml-4">
+            <h6 class="card-title">
+              <translate>Bottleneck Areas stroke-color</translate>
+            </h6>
+            <compact-picker v-model="strokeColor" />
+          </div>
+        </div>
+        <div class="mt-4">
+          <a @click.prevent="submit" class="btn btn-info text-white">
+            <translate>Send</translate>
+          </a>
+        </div>
+        <!-- <PDFTemplates /> -->
+      </div>
+      <!-- card-body -->
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.sysconfig {
+  margin-right: $offset;
+  width: 100%;
+  height: 100%;
+}
+</style>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Thomas Junk <thomas.junk@intevation.de>
+ * Bernhard Reiter <bernhard@intevation.de>
+ */
+import { Chrome } from "vue-color";
+import { Compact } from "vue-color";
+
+import { HTTP } from "@/lib/http";
+import { displayError } from "@/lib/errors.js";
+import { mapState } from "vuex";
+
+export default {
+  name: "systemconfiguration",
+  data() {
+    return {
+      sent: false,
+      strokeColor: { r: 0, g: 0, b: 0, a: 1.0 },
+      fillColor: { r: 0, g: 0, b: 0, a: 1.0 },
+      currentConfig: null
+    };
+  },
+  components: {
+    "chrome-picker": Chrome,
+    "compact-picker": Compact,
+    Spacer: () => import("../Spacer"),
+    PDFTemplates: () => import("./PDFTemplates")
+  },
+  computed: {
+    ...mapState("application", ["showSidebar"])
+  },
+  methods: {
+    submit() {
+      HTTP.put("/system/style/Bottlenecks/stroke", this.strokeColor.rgba, {
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token"),
+          "Content-type": "application/json"
+        }
+      })
+        .then()
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+
+      HTTP.put("/system/style/Bottlenecks/fill", this.fillColor.rgba, {
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token"),
+          "Content-type": "application/json"
+        }
+      })
+        .then()
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    }
+  },
+  mounted() {
+    HTTP.get("/system/style/Bottlenecks/stroke", {
+      headers: {
+        "X-Gemma-Auth": localStorage.getItem("token"),
+        "Content-type": "application/json"
+      }
+    })
+      .then(response => {
+        this.strokeColor = response.data.colour;
+      })
+      .catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: this.$gettext("Backend Error"),
+          message: `${status}: ${data.message || data}`
+        });
+      });
+
+    HTTP.get("/system/style/Bottlenecks/fill", {
+      headers: {
+        "X-Gemma-Auth": localStorage.getItem("token"),
+        "Content-type": "application/json"
+      }
+    })
+      .then(response => {
+        this.fillColor = response.data.colour;
+      })
+      .catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: this.$gettext("Backend Error"),
+          message: `${status}: ${data.message || data}`
+        });
+      });
+  }
+};
+</script>
--- a/client/src/main.js	Tue Feb 12 23:28:01 2019 +0100
+++ b/client/src/main.js	Wed Feb 13 08:00:26 2019 +0100
@@ -43,6 +43,7 @@
   faClock,
   faCloudUploadAlt,
   faCopy,
+  faDownload,
   faDrawPolygon,
   faExclamationTriangle,
   faEye,
@@ -96,6 +97,7 @@
   faClock,
   faCloudUploadAlt,
   faCopy,
+  faDownload,
   faDrawPolygon,
   faExclamationTriangle,
   faEye,
--- a/client/src/router.js	Tue Feb 12 23:28:01 2019 +0100
+++ b/client/src/router.js	Wed Feb 13 08:00:26 2019 +0100
@@ -65,7 +65,8 @@
     {
       path: "/systemconfiguration",
       name: "systemconfiguration",
-      component: () => import("./components/Systemconfiguration.vue"),
+      component: () =>
+        import("./components/systemconfiguration/Systemconfiguration.vue"),
       meta: {
         requiresAuth: true
       },
--- a/client/src/store/application.js	Tue Feb 12 23:28:01 2019 +0100
+++ b/client/src/store/application.js	Wed Feb 13 08:00:26 2019 +0100
@@ -15,6 +15,7 @@
  */
 
 import { version } from "../../package.json";
+// import { HTTP } from "../lib/http";
 
 // initial state
 const init = () => {
@@ -22,6 +23,7 @@
     appTitle: process.env.VUE_APP_TITLE,
     secondaryLogo: process.env.VUE_APP_SECONDARY_LOGO_URL,
     logoForPDF: process.env.VUE_APP_LOGO_FOR_PDF_URL,
+    pdfTemplates: [],
     showSidebar: false,
     showUsermenu: false,
     showSplitscreen: false,
@@ -103,6 +105,153 @@
     },
     searchQuery: (state, searchQuery) => {
       state.searchQuery = searchQuery;
+    },
+    pdfTemplates: (state, pdfTemplates) => {
+      state.pdfTemplates = pdfTemplates;
+    }
+  },
+  actions: {
+    loadPdfTemplates({ commit }) {
+      return new Promise(resolve => {
+        // pretend we do something async
+        setTimeout(function() {
+          commit("pdfTemplates", [
+            {
+              name: "Default Template",
+              properties: {
+                format: "landscape",
+                resolution: "120",
+                paperSize: "a4"
+              },
+              elements: [
+                {
+                  type: "textbox",
+                  position: "bottomleft",
+                  offset: { x: 2, y: 2 },
+                  width: 100,
+                  fontSize: 8,
+                  text: "Date of publication: {date} - generated by: {user}"
+                },
+                {
+                  type: "bottleneck",
+                  position: "topleft",
+                  offset: { x: 2, y: 2 }
+                },
+                {
+                  type: "legend",
+                  position: "topleft",
+                  offset: { x: 2, y: 18 }
+                },
+                {
+                  type: "scalebar",
+                  position: "bottomright",
+                  offset: { x: 2, y: 2 }
+                },
+                {
+                  type: "northarrow",
+                  position: "topright",
+                  offset: { x: 10, y: 5 },
+                  size: 2
+                }
+              ]
+            },
+            {
+              name: "Full Template",
+              properties: {
+                format: "landscape",
+                resolution: "120",
+                paperSize: "a4"
+              },
+              elements: [
+                {
+                  type: "box",
+                  position: "topleft",
+                  offset: { x: 20, y: 2 },
+                  width: 60,
+                  height: 25,
+                  color: "green"
+                },
+                {
+                  type: "text",
+                  position: "topleft",
+                  offset: { x: 23, y: 5 },
+                  width: 56,
+                  fontSize: 10,
+                  color: "greenyellow",
+                  text:
+                    "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua."
+                },
+                {
+                  type: "textbox",
+                  position: "bottomleft",
+                  offset: { x: 2, y: 2 },
+                  width: 90,
+                  padding: 3,
+                  fontSize: 7,
+                  color: "gray",
+                  background: "white",
+                  text: "Date of publication: {date} - generated by: {user}"
+                },
+                {
+                  type: "image",
+                  format: "PNG",
+                  position: "bottomleft",
+                  offset: { x: 2, y: 10 },
+                  width: 90,
+                  height: 12,
+                  border: 2
+                },
+                {
+                  type: "bottleneck",
+                  position: "topright",
+                  offset: { x: 2, y: 2 }
+                },
+                {
+                  type: "legend",
+                  position: "topright",
+                  offset: { x: 2, y: 18 }
+                },
+                {
+                  type: "scalebar",
+                  position: "bottomright",
+                  offset: { x: 2, y: 2 }
+                },
+                {
+                  type: "northarrow",
+                  position: "topleft",
+                  offset: { x: 8, y: 4 },
+                  size: 2
+                }
+              ]
+            }
+          ]);
+          resolve();
+        }, 500);
+      });
+    },
+    uploadPDFTemplates({ state, commit }, files) {
+      return new Promise((resolve, reject) => {
+        setTimeout(() => {
+          const reader = new FileReader();
+          reader.onload = event => {
+            let templates = state.pdfTemplates;
+            templates.push(JSON.parse(event.target.result));
+            commit("pdfTemplates", templates);
+            // TODO: send template to server
+          };
+          reader.onerror = error => reject(error);
+          reader.readAsText(files[0]);
+          resolve();
+        }, 1000);
+      });
+    },
+    removePDFTemplate({ state, commit }, template) {
+      let templates = state.pdfTemplates;
+      let removeIndex = templates.findIndex(t => t.name === template.name);
+      if (removeIndex !== -1) {
+        templates.splice(removeIndex, 1);
+        commit("pdfTemplates", templates);
+      }
     }
   }
 };