mapPrint.js

'use strict';

// ugly way to add rgbcolor to global scope so it can be used by canvg inside the viewer; this is done because canvg uses UMD loader and has rgbcolor as internal dependency; there is no elegant way around it; another approach would be to clone canvg and change its loader;
window.RGBColor = require('rgbcolor');
const canvg = require('canvg-origin');

/**
  * @ngdoc module
  * @name mapPrint
  * @module geoAPI
  * @description
  *
  * The `mapPrint` module provides map print and export image related functions.
  *
  * This module exports an object with the following functions
  * - `printMap`
  *
  * NOTE: unit tests might be difficult to implement as DOM is required...
  */

/**
* Generate the image from the esri print task
*
* @param {Object} esriBundle bundle of API classes
* @param {Object} geoApi geoApi to determine if we are in debug mode
* @param {Object} map esri map object
* @param {Object} options options for the print task
*                           url - for the esri geometry server
*                           format - output format
* @return {Promise} resolving when the print task created the image
*                           resolve with a "response: { url: value }" where url is the path
*                           for the print task export image
*/
function generateServerImage(esriBundle, geoApi, map, options) {
    // create esri print object with url to print server
    const printTask = esriBundle.PrintTask(options.url, { async: true });
    const printParams = new esriBundle.PrintParameters();
    const printTemplate = new esriBundle.PrintTemplate();

    // each layout has an mxd with that name on the server. We can modify and add new layout (mxd)
    // we only support MAP_ONLY for now. See https://github.com/fgpv-vpgf/fgpv-vpgf/issues/1160
    printTemplate.layout = 'MAP_ONLY';

    // only use when layout is MAP_ONLY
    printTemplate.exportOptions = {
        height: map.height,
        width: map.width,
        dpi: 96
    };

    // pdf | png32 | png8 | jpg | gif | eps | svg | svgz
    printTemplate.format = options.format;
    printTemplate.showAttribution = false;

    // define whether the printed map should preserve map scale or map extent.
    // if true, the printed map will use the outScale property or default to the scale of the input map.
    // if false, the printed map will use the same extent as the input map and thus scale might change.
    // we always use true because the output image is the same size as the map (we have the same extent and
    // same scale)
    // we fit the image later because trying to fit the image with canvg when we add user added
    // layer is tricky!
    printTemplate.preserveScale = true;

    // set map and template
    printParams.map = map;
    printParams.template = printTemplate;

    // need to hide svg layers since we can generate an image for them locally
    const svgLayers = hideLayers(map);

    const printPromise = new Promise((resolve, reject) => {
        // can be use to debug print task. Gives parameters to call directly the print task from it's interface
        // http://resources.arcgis.com/en/help/rest/apiref/exportwebmap_spec.html
        // http://snipplr.com/view/72400/sample-json-representation-of-an-esri-web-map-for-export-web-map-task
        // const mapJSON = printTask._getPrintDefinition(map, printParams);
        // console.log(JSON.stringify(mapJSON));

        // TODO: catch esriJobFailed. it does not trigger the complete or the error event. Need a way to catch it!
        // execute the print task
        printTask.execute(printParams,
            response =>
                resolve(convertImageToCanvas(response.url)),
            error =>
                reject(error)
        );
    });

    // show user added previously visible for canvg to create canvas
    showLayers(svgLayers);

    return printPromise;
}

/**
* Set svg-based layer visibility to false to avoid CORS error
*
* @param {Object} map esri map object
* @return {Array} layer array of layers where visibility is true
*/
function hideLayers(map) {
    return map.graphicsLayerIds
        .map(layerId => map.getLayer(layerId))
        .filter(layer => layer.visible)
        .map(layer => {
            layer.setVisibility(false);
            return layer;
        });
}

/**
* Set user added layer visibility to true for those whoe where visible
*
* @param {Array} layers array of graphic layers to set visibility to true
*/
function showLayers(layers) {
    layers.forEach((layer) => layer.setVisibility(true));
}

/**
* Create a canvas from the user added layers (svg tag)
*
* @param {Object} map esri map object
* @return {Promise} resolving when the canvas have been created
*                           resolve with a canvas element with user added layer on it
*/
function generateLocalCanvas(map) {
    // convert svg to text (use map id to select the svg container)
    const svgtext = document.getElementById(`esri\.Map_${map.id.split('_')[1]}_gc`).outerHTML;
    const localCanvas = document.createElement('canvas'); // create canvas element

    const generationPromise = new Promise((resolve, reject) => {
        // parse the svg
        // convert svg text to canvas and stuff it into localCanvas canvas dom node

        // wrapping in try/catch since canvg has NO error handling; not sure what errors this can catch though
        try {
            canvg(localCanvas, svgtext, {
                useCORS: true,
                ignoreAnimation: true,
                ignoreMouse: true,
                renderCallback: () =>
                    resolve(localCanvas)
            });
        } catch (error) {
            reject(error);
        }
    });

    return generationPromise;
}

/**
* Convert an image to a canvas element
*
* @param {String} url image url to convert (result from the esri print task)
* @return {Promise} conversion promise resolving into a canvas of the image
*/
function convertImageToCanvas(url) {
    const canvas = document.createElement('canvas');
    const image = document.createElement('img'); // create image node
    image.crossOrigin = 'Anonymous'; // configure the CORS request

    const conversionPromise = new Promise((resolve, reject) => {
        image.addEventListener('load', () => {

            canvas.width = image.width;
            canvas.height = image.height;
            canvas.getContext('2d').drawImage(image, 0, 0); // draw image onto a canvas

            // return canvas
            resolve(canvas);
        });
        image.addEventListener('error', error =>
            reject(error));
    });

    // set image source to the one generated from the print task
    image.src = url;

    return conversionPromise;
}

/**
* Generate the print image by combining the output from esri print task and
* svg export of the user added layers.
*
* @param {Object} esriBundle bundle of API classes
* @param {Object} geoApi geoApi to determine if we are in debug mode
* @return {Object} with two promises - local and server canvas; each promise resolves with a corresponding canvas; each promise can error separately if the canvas cannot be generated returning whatever error message was supplied; it's responsibility of the caller to handle errors appropriately
*/
function printMap(esriBundle, geoApi) {

    return (map, options) => {

        // generate image with server layer from esri print task
        return {
            localPromise: generateLocalCanvas(map),
            serverPromise: generateServerImage(esriBundle, geoApi, map, options)
        };
    };
}

// Print map related modules
module.exports = (esriBundle, geoApi) => {
    return {
        print: printMap(esriBundle, geoApi),
    };
};