view client/src/components/Pdftool.vue @ 2227:9b545e470b94 pdf-export

PDF generation: logo image, adjusted templates The default logo file will now be added to the PDF if an image element is specified without url.
author Markus Kottlaender <markus@intevation.de>
date Tue, 12 Feb 2019 14:33:36 +0100
parents e21bfc76816d
children c3cc21dee75d
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 pointer"
          @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>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">
            <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,
          this.pdf.width,
          this.pdf.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");
        }
        // 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
      map.setSize(mapSizeForPrint);
      map.getView().fit(mapExtent, { size: mapSizeForPrint });
    },
    addRoundedBox(x, y, w, h, color, rounding) {
      // draws a rounded background box at (x,y) width x height
      // using jsPDF units
      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);
    },
    // 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
      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, "white", rounding);

      // 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
      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");
    },
    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;

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

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

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

        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);
      }
    },
    replacePlaceholders(text) {
      if (text.includes("{date}")) {
        text = text.replace("{date}", new Date().toLocaleString(locale2));
      }
      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>