view client/src/components/Pdftool.vue @ 2221:74c7d84f93d7 pdf-export

PDF generation: Don't change template on form changes. Selecting a template also changes the other form fields to the according values of the template. The bahavior was the same in the other direction. If you change something in the form, a matching template was automatically selected. When there is no matching template, no template is selected which leads to problems in the PDF generation. To make overriding template values possible without unselecting the current template, this behavior was removed.
author Markus Kottlaender <markus@intevation.de>
date Mon, 11 Feb 2019 10:26:03 +0100
parents d926292d81b6
children 318da99d406a
line wrap: on
line source

<template>
  <div
    :class="[
      'box ui-element rounded bg-white text-nowrap',
      { expanded: showPdfTool }
    ]"
  >
    <div style="width: 20rem">
      <h6 class="mb-0 py-2 px-3 border-bottom d-flex align-items-center">
        <font-awesome-icon icon="file-pdf" class="mr-2"></font-awesome-icon>
        <translate>Generate PDF</translate>
        <font-awesome-icon
          icon="times"
          class="ml-auto text-muted"
          @click="$store.commit('application/showPdfTool', false)"
        ></font-awesome-icon>
      </h6>
      <div class="p-3 text-left">
        <select
          @change="applyTemplateToForm"
          v-model="form.template"
          class="form-control d-block mb-2 w-100 font-weight-bold"
        >
          <option
            v-for="template in pdfTemplates"
            :value="template"
            :key="template.name"
          >
            <translate>{{ template.name }}</translate>
          </option>
        </select>
        <select
          v-model="form.format"
          class="form-control form-control-sm d-block mb-2 w-100"
        >
          <option value="landscape"><translate>landscape</translate></option>
          <option value="portrait"><translate>portrait</translate></option>
        </select>
        <div class="d-flex">
          <div class="flex-fill mr-2">
            <select
              v-model="form.resolution"
              class="form-control form-control-sm mb-2 d-block w-100"
            >
              <option value="80">
                <translate>low resolution (80 dpi)</translate>
              </option>
              <option value="120">
                <translate>medium resolution (120 dpi)</translate>
              </option>
              <option value="200">
                <translate>high resolution (200 dpi)</translate>
              </option>
            </select>
          </div>
          <div class="flex-fill ml-2">
            <select
              v-model="form.paperSize"
              class="form-control form-control-sm mb-2 d-block w-100"
            >
              <option value="a4"><translate>A4</translate></option>
              <option value="a3"><translate>A3</translate></option>
            </select>
          </div>
        </div>
        <!--
        <small class="d-block my-2">
          <input
            type="radio"
            id="pdfexport-downloadtype-download"
            value="download"
            v-model="form.downloadType"
            selected
          />
          <label for="pdfexport-downloadtype-download" class="ml-1 mr-2">
            <translate>Download</translate>
          </label>
          <input
            type="radio"
            id="pdfexport-downloadtype-open"
            value="open"
            v-model="form.downloadType"
          />
          <label for="pdfexport-downloadtype-open" class="ml-1">
            <translate>Open in new window</translate>
          </label>
        </small>
        -->
        <button
          @click="download"
          type="button"
          :disabled="!readyToGenerate"
          class="btn btn-sm btn-info d-block w-100 mt-2"
        >
          <translate>Generate PDF</translate>
        </button>
      </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, 2019 by via donau
 *   – Österreichische Wasserstraßen-Gesellschaft mbH
 * Software engineering by Intevation GmbH
 *
 * Author(s):
 * * Markus Kottländer <markus.kottlaender@intevation.de>
 * * Bernhard E. Reiter <bernhard@intevation.de>
 * * Fadi Abbud <fadi.abbud@intevation.de>
 */
import { mapGetters, mapState } from "vuex";
import jsPDF from "jspdf";
import { getPointResolution } from "ol/proj.js";
import locale2 from "locale2";

var paperSizes = {
  // in millimeter, landscape [width, height]
  a3: [420, 297],
  a4: [297, 210]
};

export default {
  name: "pdftool",
  data() {
    return {
      form: {
        template: null,
        format: "landscape",
        paperSize: "a4",
        downloadType: "download",
        resolution: "80"
      },
      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", "pdfTemplates"]),
    ...mapState("bottlenecks", ["selectedBottleneck", "selectedSurvey"]),
    ...mapState("map", ["openLayersMap", "isolinesLegendImgDataURL"]),
    ...mapGetters("map", ["getLayerByName"]),
    ...mapState("user", ["user"])
  },
  methods: {
    // When a template is chosen from the dropdown, its propoerties are
    // applied to the rest of the form.
    applyTemplateToForm() {
      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;
      }
    },
    download() {
      let template = this.form.template;

      // disable button while working on it
      this.readyToGenerate = false;

      console.log(
        "will generate pdf with",
        this.form.paperSize,
        this.form.format,
        this.form.resolution
      );

      if (this.form.format !== "portrait") {
        // landscape, default
        this.pdf.width = paperSizes[this.form.paperSize][0];
        this.pdf.height = paperSizes[this.form.paperSize][1];
      } else {
        // switch width and height
        this.pdf.width = paperSizes[this.form.paperSize][1];
        this.pdf.height = paperSizes[this.form.paperSize][0];
      }

      // FUTURE: consider margins

      // dots per mm = dots per inch / (25.4 mm/inch)
      var pixelsPerMapMillimeter = this.form.resolution / 25.4;
      var mapSizeForPrint = [
        // in pixel
        Math.round(this.pdf.width * pixelsPerMapMillimeter),
        Math.round(this.pdf.height * pixelsPerMapMillimeter)
      ];

      // generate PDF and open it
      // our units are milimeters; width 0 x height 0 is left upper corner

      // Step 1 prepare and save current map extend
      // Then add callback "rendercomplete" for Step 3
      //    which will generate the pdf and resets the map view
      // Step 2 which starts rendering a map with the necessary image size

      var map = this.openLayersMap;
      var mapSize = map.getSize(); // size in pixels of the map in the DOM
      // Calculate the extent for the current view state and the passed size.
      // The size is the pixel dimensions of the box into which the calculated
      // extent should fit.
      var mapExtent = map.getView().calculateExtent(mapSize);

      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", event => {
        let canvas = event.context.canvas;

        // because we are using Web Mercator, a pixel represents
        // a differently sizes spot depending on the place of the map.
        // So we use a value calculated from the center of the current view.
        let view = map.getView();
        let proj = view.getProjection();
        let metersPerPixel = // average meters (reality) per pixel (map)
          getPointResolution(proj, view.getResolution(), view.getCenter()) *
          proj.getMetersPerUnit();
        // DEBUG console.log("metersPerPixel = ", metersPerPixel);

        let scaleNominator = Math.round(
          // the x in 1:x map scale
          1000 * pixelsPerMapMillimeter * metersPerPixel
        );
        console.log("scaleNominator = ", scaleNominator);

        var data = canvas.toDataURL("image/jpeg");
        this.pdf.doc.addImage(data, "JPEG", 0, 0);

        if (template) {
          template.elements.forEach(e => {
            switch (e.type) {
              case "text": {
                this.addText(
                  e.position,
                  e.offset,
                  e.width,
                  e.height,
                  e.padding,
                  e.fontSize,
                  e.color,
                  e.text
                );
                break;
              }
              case "image": {
                this.addImage(
                  e.url,
                  e.format,
                  e.position,
                  e.offset,
                  e.width,
                  e.height,
                  e.border
                );
                break;
              }
              case "bottleneck": {
                this.addBottleneckInfo(e.position, e.offset);
                break;
              }
              case "legend": {
                this.addLegend(e.position, e.offset);
                break;
              }
              case "scalebar": {
                this.addScaleBar(scaleNominator, e.position, e.offset);
                break;
              }
              case "northarrow": {
                this.addNorthArrow(e.position, e.offset, e.size);
                break;
              }
            }
          });

          this.pdf.doc.save("map.pdf");
        }
        // reset to original size
        map.setSize(mapSize);
        map.getView().fit(mapExtent, { size: mapSize });

        // as we are done: re-enable button
        this.readyToGenerate = true;
      });

      // trigger rendering
      this.prepareRendering(function() {
        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(x, y, w, h) {
      // draws a rounded background box at (x,y) width x height
      // using jsPDF units
      this.pdf.doc.setDrawColor(255, 255, 255);
      this.pdf.doc.setFillColor(255, 255, 255);
      this.pdf.doc.roundedRect(x, y, w, h, 3, 3, "FD");
    },
    addScaleBar(scaleNominator, position, offset) {
      // scaleNominator is the x in 1:x of the map scale

      // hardcode maximal width for now
      let maxWidth = 80; // in mm

      // reduce width until we'll find a nice number for printing
      // strategy:
      //           1. check which unit prefix we shall use to get [10:10000[
      //           2. using a mapping for the leading digit to get [1:10[
      //           3. select a smaller number which is nicely dividable
      //           4. scale up again to get length in paper mm and to be shown

      // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/log10#Polyfill
      let log10 =
        Math.log10 || // more precise, but unsupported by IE
        function(x) {
          return Math.log(x) * Math.LOG10E;
        };

      let maxLength = maxWidth * scaleNominator;

      let unit = "mm";
      let unitConversionFactor = 1;
      if (maxLength >= 1e7) {
        // >= 10 km
        unit = "km";
        unitConversionFactor = 1e6;
      } else if (maxLength >= 1e4) {
        // >= 10 m
        unit = "m";
        unitConversionFactor = 1e3;
      }

      maxLength /= unitConversionFactor;

      // DEBUG console.log(maxLength, unit);
      let unroundedLength = maxLength;
      let numberOfDigits = Math.floor(log10(unroundedLength));
      let factor = Math.pow(10, numberOfDigits);
      let mapped = unroundedLength / factor;
      // DEBUG console.log(mapped);

      var length = Math.floor(maxLength); // just to have an upper limit

      // manually only use numbers that are very nice to devide by 4
      // note that this is taken into account for rounding later
      if (mapped > 8) {
        length = 8 * factor;
      } else if (mapped > 4) {
        length = 4 * factor;
      } else if (mapped > 2) {
        length = 2 * factor;
      } else {
        length = factor;
      }

      let size = (length * unitConversionFactor) / scaleNominator / 4;
      let fullSize = size * 4;

      // 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 - 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);

      // 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.setFontSize(5);
      this.pdf.doc.text(scaleBarX, scaleBarY - 1, "0");
      // /4 and could give 2.5. We still round, because of floating point arith
      this.pdf.doc.text(
        scaleBarX + size - 1,
        scaleBarY - 1,
        (Math.round((length * 10) / 4) / 10).toString()
      );
      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;

      // 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
      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(position, offset, width, height, padding, fontSize, color, text) {
      // 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 - 8;
      }
      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
        y = this.pdf.height - offset.y - 10;
      }

      this.addRoundedBox(x, y, width, height);

      // replace placeholders
      if (text.includes("{date}")) {
        text = text.replace("{date}", new Date().toLocaleString(locale2));
      }
      if (text.includes("{user}")) {
        text = text.replace("{user}", this.user);
      }

      // split the incoming string to an array, each element is a string of words in a single line
      var textLines = this.pdf.doc.splitTextToSize(text, width - 2 * padding);
      this.pdf.doc.setTextColor(color);
      this.pdf.doc.setFontSize(fontSize);
      this.pdf.doc.text(x + padding, y + padding, textLines);
    },
    addImage(url, format, position, offset, width, height, border) {
      // 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;
      }

      if (border) {
        this.addRoundedBox(x, y, width, height);
      }

      let image = new Image();
      image.src = url;
      this.pdf.doc.addImage(
        image,
        x + border,
        y + border,
        width - 2 * border,
        height - 2 * border
      );
    },
    addLegend(position, offset) {
      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;

        // 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);
        this.pdf.doc.addImage(
          legendImage,
          x + padding,
          y + padding,
          width - 2 * padding,
          height - 2 * padding
        );
      }
    },
    addBottleneckInfo(position, offset) {
      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;

        // 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);

        this.pdf.doc.setFont("times", "normal");
        this.pdf.doc.setFontSize(9);

        let str, w;

        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);

        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);

        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);
      }
    }
  },
  mounted() {
    this.$store.dispatch("application/loadPdfTemplates").then(() => {
      this.form.template = this.pdfTemplates[0];
    });
  }
};
</script>