Source: management-system/src/frontend/helpers/process-export/process-export.js

import jsPDF from 'jspdf';
import JSZip from 'jszip';
import Viewer from 'bpmn-js/lib/Viewer';

import { getExporterName, getExporterVersion } from '@proceed/bpmn-helper';
import {
  getCleanedUpName,
  prepareProcesses,
} from '@/frontend/helpers/process-export/export-preparation.js';

import uuid from 'uuid';

let viewerElement;

/**
 *  Export selected processes to the selected format or a zip if we export multiple files.
 *
 * @param {Object[]} allProcesses all known processes to search for referenced call activities
 * @param {Object[]} selectedProcesses - all processes that were selected for export
 * @param {Object} selectedOption - selected export options
 */
export async function exportSelectedProcesses(allProcesses, selectedProcesses, selectedOption) {
  // create name for the export file
  let fileName = 'PROCEED';
  if (selectedProcesses.length === 1) {
    const [{ name }] = selectedProcesses;
    fileName = `${fileName}_${getCleanedUpName(name)}`;
  } else {
    fileName = `${fileName}_Multiple-Processes`;
  }

  // deep copy to prevent updating the original process objects
  selectedProcesses = JSON.parse(JSON.stringify(selectedProcesses));
  const processesToExport = await prepareProcesses(allProcesses, selectedProcesses, selectedOption);

  // create the files to export
  const { exportFile, exportFormat } = await getExportFile(processesToExport, selectedOption);

  // gives hint which kind of files are exported when exporting a zip file
  const fileNameSuffix = exportFormat !== selectedOption.format ? `_${selectedOption.format}` : '';

  //Download single file
  triggerExport(`${fileName}${fileNameSuffix}.${exportFormat}`, exportFile);
}

/**
 * Checks if we have to export more than one file which means we have to use a zip file
 *
 * @param {Object[]} processesToExport the process(es) we want to export
 * @param {Object} options the export options (e.g. which file format to export to)
 * @returns {Boolean} if the export has to be in form of a zip
 */
function needZipExport(processesToExport, options) {
  // we have to use a zip file to export more than one process
  if (processesToExport.length > 1) {
    return true;
  } else if (options.format === 'pdf') {
    // the pdf contains all the information needed for one process
    return false;
  }

  const [exportProcess] = processesToExport;
  // we need to use a zip if we want to export user tasks along with the process bpmn
  if (exportProcess.userTasks && Object.keys(exportProcess.userTasks).length) {
    return true;
  }
  // we don't import callActivities and subprocesses into the directory of the process containing them on a bpmn export
  // call activities are imported into their own directory and should lead to the first check being true
  if (options.format !== 'bpmn') {
    // we need to use a zip if we want to export subprocesses info along with the process info
    if (exportProcess.callActivities && exportProcess.callActivities.length) {
      return true;
    }
    if (exportProcess.collapsedSubprocesses && exportProcess.collapsedSubprocesses.length) {
      return true;
    }
  }

  return false;
}

/**
 * Creates the file we want to export
 *
 * @param {Object[]} processesToExport the processes we want to export and their information
 * @param {Object} options the selected export options
 * @returns {Promise.<Object>} the file to export
 */
async function getExportFile(processesToExport, options) {
  //Creating temporary element for BPMN Viewer
  viewerElement = document.createElement('div');

  //Assiging process id to temp element and append to DOM
  viewerElement.id = 'canvas_' + uuid.v4();
  document.body.appendChild(viewerElement);

  //Initiate BPMN Viewer
  let viewer = new Viewer({ container: '#' + viewerElement.id });

  let exportFile;
  let exportFormat;
  if (needZipExport(processesToExport, options)) {
    exportFormat = 'zip';
    exportFile = await getExportZip(processesToExport, viewer, options);
  } else {
    // export single file in the selected format
    exportFormat = options.format;
    exportFile = await getProcessFile(processesToExport[0], viewer, options);
  }

  //remove temporary viewer element from DOM
  document.body.removeChild(viewerElement);

  return { exportFile, exportFormat };
}

/**
 * Creates the zip file containing all the files we want from the export
 *
 * @param {Object[]} processesToExport the processes we want to export
 * @param {Object} viewer a bpmn-io viewer
 * @param {Object} options the selected export options
 * @returns {Promise.<Object>} the zip file to export
 */
async function getExportZip(processesToExport, viewer, options) {
  const zip = new JSZip();

  // add directory for every process
  for (process of processesToExport) {
    await createProcessContainer(process, viewer, options, zip);
  }

  return await zip.generateAsync({ type: 'blob' });
}

/**
 * Creates a pdf in case of pdf export or a zip directory in case we export multiple non pdf files
 *
 * will add the pdf to the given optionally given zip
 *
 * @param {Object} process the process to export
 * @param {Object} viewer the bpmn-js process viewer
 * @param {Object} options the selected export options
 * @param {Object} [zip] the zip we want to create the container in in case of multi process export
 * @returns {Promise.<Object>} either a zip directory or a pdf file
 */
async function createProcessContainer(process, viewer, options, zip) {
  // create a valid name for the directory the process is stored in
  const processFolderName = getCleanedUpName(process.name);

  let container;
  // create a pdf file or a zip directory
  if (options.format === 'pdf') {
    // initialize Pdf Object
    const pdf = new jsPDF({
      orientation: 'l',
      unit: 'mm',
      format: 'a4',
      compressPdf: true,
    });

    //Delete Default Page from Pdf
    pdf.deletePage(1);

    container = pdf;
  } else if (zip) {
    // create a directory for the process
    container = zip.folder(processFolderName);
  } else {
    throw new Error('Unallowed use of createProcessContainer!');
  }

  // add the main process file
  await addFile(container, process, viewer, options);

  await addAdditionalContent(container, process, viewer, options);

  // create the final pdf file
  if (options.format === 'pdf') {
    container = container.output('blob');

    // add the pdf to the optional zip
    if (zip) {
      zip.file(`${processFolderName}.pdf`, container);
    }
  }

  return container;
}

/**
 * Adds a file to the given pdf/zip directory
 *
 * @param {Object} container either a pdf file or a zip directory
 * @param {Object} process the process we want to create a file of
 * @param {Object} viewer the viewer to get image data from
 * @param {Object} options the selected export options
 */
async function addFile(container, process, viewer, options) {
  let file = await getProcessFile(process, viewer, options, container);

  const fileName = getCleanedUpName(process.name || process.elementId);

  if (options.format !== 'pdf') {
    addZipFile(container, fileName, file, options);
  }
}

/**
 * Adds optional content to the pdf/directory (e.g. User Tasks for bpmn export or called processes for image exports)
 *
 * @param {Object} container a pdf or zip directory
 * @param {*} process
 * @param {*} viewer
 * @param {*} options
 */
async function addAdditionalContent(container, process, viewer, options) {
  if (options.format === 'bpmn') {
    createUserTasks(container, process);
  } else {
    // create image files for all subprocesses and callActivities
    if (process.collapsedSubprocesses && Array.isArray(process.collapsedSubprocesses)) {
      for (const subprocess of process.collapsedSubprocesses) {
        await addFile(container, subprocess, viewer, options);
      }
    }
    if (process.callActivities && Array.isArray(process.callActivities)) {
      for (const callActivity of process.callActivities) {
        await addFile(container, callActivity, viewer, options);
        await addAdditionalContent(container, callActivity, viewer, options);
      }
    }
  }
}

/**
 * Adds a file to the given directory inside the zip
 *
 * @param {Object} processFolder the directory the file will be added to
 * @param {Object} fileName the name to be used for the zip file
 * @param {Object} file the content of the new zip file
 * @param {Object} options the selected export options
 */
function addZipFile(processFolder, fileName, file, options) {
  // tells jszip that the given file is base64 encoded (needed for png)
  let fileOptions =
    options.format === 'png'
      ? {
          base64: true,
        }
      : {};
  // create the file and add it to the directory
  processFolder.file(`${fileName}.${options.format}`, file, fileOptions);
}

/**
 * Creates a single file for a process
 *
 * @param {Object} process the process from which we want to create the file
 * @param {Object} viewer a bpmn-js viewer
 * @param {Object} options the selected export options
 * @param {Boolean}  [container] pdf or zip directory the file will be added to
 * @returns {Promise.<Object|String>} the file or string with the process information
 */
async function getProcessFile(process, viewer, options, container) {
  // Import the process into the viewer to allow image creation
  await viewer.importXML(process.bpmn);

  switch (options.format) {
    case 'bpmn':
      if (!container) {
        return new Blob([process.bpmn], {
          type: 'application/bpmn+xml',
        });
      } else {
        return process.bpmn;
      }
    case 'svg':
      return getSVG(viewer, container);
    case 'png':
      return await getPNG(viewer, container, options.additionalParam.resolution);
    default:
      // either add to existing pdf or initialize pdf
      if (container) {
        return await addToPDF(container, viewer, process, options);
      } else {
        return await createProcessContainer(process, viewer, options);
      }
  }
}

/**
 * create PDF with Properties and Values
 *
 * @param {Object} pdf the pdf we want to add to
 * @param {String} imageURI
 * @param {Object} process the process we add the image for
 * @param {Boolean} isHeadingRequested if the images in the pdf should be anotated
 * @param {String} svgWidth
 * @param {String} svgHeight
 */
export function setPdfPropsAndValues(
  pdf,
  imageURI,
  process,
  isHeadingRequested,
  svgWidth,
  svgHeight
) {
  const { name, elementId, description = '', departments = [] } = process;
  let keywords = 'BPMN';

  // adding a new page, second parameter orientation: p - portrait, l - landscape
  pdf.addPage([svgWidth, svgHeight], svgHeight > svgWidth ? 'p' : 'l');

  //Getting PDF Documents width and height
  const pdfWidth = pdf.internal.pageSize.getWidth() - 10;
  const pdfHeight = pdf.internal.pageSize.getHeight() - 10;

  //Setting pdf font size
  pdf.setFontSize(20);

  //Adding Header to the Pdf
  if (isHeadingRequested) {
    let type = 'Process';
    if (process.elementId) {
      type = 'Subprocess';
    }
    pdf.text(10, 10, `${type}: ${name || elementId} \n`);
  }

  if (Array.isArray(departments)) {
    const departmentsString = departments.map((d) => d.name).join(', ');
    keywords =
      `${departmentsString != '' ? departmentsString : departments.join(', ')}, ` + keywords;
  }

  //Adding Meta Information to the PDF
  pdf.setProperties({
    title: `${name}`,
    subject: `${description}`,
    keywords: keywords,
    creator: `${getExporterName()} v${getExporterVersion()}`,
  });

  /**
   * Adding image to pdf document
   * If isHeadingRequested is true then assign value to 'y-axis'
   */
  pdf.addImage(
    imageURI,
    'PNG',
    5,
    isHeadingRequested ? 10 : 0,
    pdfWidth,
    pdfHeight,
    undefined,
    'FAST'
  );
}

/**
 * Adds an image of a (sub)process to the given pdf
 *
 * @param {Object} pdf the pdf file to write to
 * @param {Object} viewer the bpmn-js viewer to get the image data from
 * @param {Object} process the (sub)process we want to create the pdf data from
 * @param {Object} options the selected export options
 * @returns {Promise.<Object>} the updated pdf file
 */
export async function addToPDF(pdf, viewer, process, options) {
  let { resolution } = options.additionalParam;
  // create the image
  let uri = await getPNG(viewer, false, resolution);

  const isHeadingRequested = options.format ? true : false;

  // get image dimensions
  let svg = await getSVG(viewer, true);
  let width = parseFloat(svg.split('width="')[1].split('"')[0]);
  let height = 20 + parseFloat(svg.split('height="')[1].split('"')[0]);
  // relevant for the server: if pdf.addImage throws an error, try again with minimum resolution
  try {
    setPdfPropsAndValues(pdf, uri, process, isHeadingRequested, width, height);
  } catch (err) {
    if (resolution !== 1) {
      const pageCount = pdf.internal.getNumberOfPages();
      pdf.deletePage(pageCount);
      options.resolution -= 1;
      return await addToPDF(pdf, viewer, process, options);
    }
  }

  return pdf;
}

/**
 * Creates a SVG from the modeler content
 *
 * @param {Object} viewer the bpmn-js viewer to get the data from
 * @param {Boolean} isMultiDownload if the SVG will be added to some kind of container file
 */
export async function getSVG(viewer, isMultiDownload) {
  let svgBlob;
  let svgFile;
  try {
    const { svg } = await viewer.saveSVG();
    //Combining SVG and its content type
    svgBlob = new Blob([svg], {
      type: 'image/svg+xml',
    });
    svgFile = svg;
  } catch (err) {
    console.debug(err);
  }

  return isMultiDownload ? svgFile : svgBlob;
}

/**
 * Creates a png from the process in the viewer
 *
 * @param {Object} viewer the bpmn-js viewer to get the image from
 * @param {Boolean} isMultiDownload if the png will be added to some kind of containing file
 * @param {Number} resolution
 * @returns {Promise.<String>} the png data
 */
export function getPNG(viewer, isMultiDownload, resolution) {
  let uri;
  const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/;
  return new Promise(async (resolve, reject) => {
    let svg = await getSVG(viewer, true);

    //Getting width and height from BPMN SVG
    let width = svg.split('width="')[1].split('"')[0];
    let height = svg.split('height="')[1].split('"')[0];

    //Initiating Image Element
    let image = new Image(width, height);

    //Combining SVG and its content type
    let svgString = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });

    //Creating Canvas out of SVG
    let canvas = document.createElement('canvas');

    image.onload = () => {
      for (let scale = resolution; scale >= 1; scale -= 1) {
        try {
          canvas.width = scale * image.width;
          canvas.height = scale * image.height;
          //Creating 2D Canvas
          let ctx = canvas.getContext('2d');
          //prevent from bluring the pixels
          ctx.imageSmoothingEnabled = false;

          ctx.scale(scale, scale);

          //Drawing Image on Canvas
          ctx.drawImage(image, 0, 0, width, height);

          //Getting URI for Image in PNG **Default = PNG
          uri = canvas.toDataURL('image/png');
          if (DATA_URL_REGEX.test(uri)) {
            const headerlessImage = uri.replace('data:image/png;base64,', '');
            resolve(isMultiDownload ? headerlessImage : uri);
            break;
          }

          //Release Object URL, so browser dont keep reference
          URL.revokeObjectURL(uri);
        } catch (err) {}
      }
    };
    //Takes BLOB, File and Media Source and returns object url
    image.src = URL.createObjectURL(svgString);
  });
}

/**
 * Creates user task directory and files in zip
 *
 * @param {Object} processFolder the directory in the zip to write to
 */
export function createUserTasks(processFolder, process) {
  const { userTasks } = process;

  if (!userTasks) {
    return;
  }

  const userTaskIds = Object.keys(userTasks);
  //Combining Process with its supporting files
  if (userTaskIds.length > 0) {
    //If its multi download then attach files to its specific folder else direct to zipObject
    const userTaskFolder = processFolder.folder('User-Tasks');

    //Combining Process with its supporting files
    for (const userTaskId of userTaskIds) {
      userTaskFolder.file(`${userTaskId}.html`, userTasks[userTaskId]);
    }
  }
}

/**
 * Handles the export of the created file
 *
 * @param {String} fileName name used to download file
 * @param {String|Object} objectContent content, either as Blob or objectURL DOMString
 */
export async function triggerExport(fileName, objectContent) {
  // check if objectContent is an objectURL
  // otherwise create a blob, for the following reason: https://stackoverflow.com/questions/16761927/aw-snap-when-data-uri-is-too-large

  const objectURL =
    typeof objectContent === 'string'
      ? URL.createObjectURL(await fetch(objectContent).then((res) => res.blob()))
      : URL.createObjectURL(objectContent);

  // Creating Anchor Element to trigger download feature
  const aLink = document.createElement('a');

  // Setting anchor tag properties
  aLink.style.display = 'none';
  aLink.download = fileName;
  aLink.href = objectURL;

  // Setting anchor tag to DOM
  document.body.appendChild(aLink);
  aLink.click();
  document.body.removeChild(aLink);

  // Release Object URL, so browser dont keep reference
  URL.revokeObjectURL(objectURL);
}