Mercurial > gemma
view client/src/components/Pdftool.vue @ 2222:318da99d406a pdf-export
PDF generation: fix text size calculation
For jsPDF to correctly calculate the display size of text, it's important
that all font properties are set before the calculation. Makes sense...
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Mon, 11 Feb 2019 10:38:36 +0100 |
parents | 74c7d84f93d7 |
children | 85142493096c |
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 this.pdf.doc.setTextColor(color); this.pdf.doc.setFontSize(fontSize); var textLines = this.pdf.doc.splitTextToSize(text, width - 2 * padding); 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>