From 66c518c3b66fe38ea84c91352476556885f7411f Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 8 Nov 2023 17:15:28 +0100 Subject: [PATCH 01/10] Added png export --- .../components/process-export.tsx | 70 ++++-- .../lib/process-export/PDFPageBuilder.ts | 29 +-- .../lib/process-export/export-preparation.ts | 3 +- .../lib/process-export/image-export.ts | 233 ++++++++++++++++++ .../lib/process-export/index.ts | 123 +-------- .../lib/process-export/util.ts | 25 ++ 6 files changed, 325 insertions(+), 158 deletions(-) create mode 100644 src/management-system-v2/lib/process-export/image-export.ts diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 9bca0b9fa..aa77c1695 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -2,7 +2,17 @@ import React, { useState } from 'react'; -import { Modal, Checkbox, Radio, RadioChangeEvent, Space, Flex, Divider, Tooltip } from 'antd'; +import { + Modal, + Checkbox, + Radio, + RadioChangeEvent, + Space, + Flex, + Divider, + Tooltip, + Slider, +} from 'antd'; import type { CheckboxValueType } from 'antd/es/checkbox/Group'; import { exportProcesses } from '@/lib/process-export'; @@ -12,6 +22,7 @@ const exportTypeOptions = [ { label: 'BPMN', value: 'bpmn' }, { label: 'PDF', value: 'pdf' }, { label: 'SVG', value: 'svg' }, + { label: 'PNG', value: 'png' }, ]; const exportSubOptions = { @@ -62,6 +73,18 @@ const exportSubOptions = { tooltip: 'Also export content of all collapsed subprocesses', }, ], + png: [ + { + label: 'with referenced processes', + value: 'imports', + tooltip: 'Also export all referenced processes used in call-activities', + }, + { + label: 'with collapsed subprocesses', + value: 'subprocesses', + tooltip: 'Also export content of all collapsed subprocesses', + }, + ], }; type ProcessExportModalProps = { @@ -73,6 +96,8 @@ const ProcessExportModal: React.FC = ({ processes = [], const [selectedType, setSelectedType] = useState(); const [selectedOptions, setSelectedOptions] = useState(['metaData']); const [isExporting, setIsExporting] = useState(false); + const [maxPngScalingFactor, setMaxPngScalingFactor] = useState(10); + const [pngScalingFactor, setPngScalingFactor] = useState(1); const handleTypeSelectionChange = ({ target: { value } }: RadioChangeEvent) => { setSelectedType(value); @@ -97,6 +122,7 @@ const ProcessExportModal: React.FC = ({ processes = [], imports: selectedOptions.some((el) => el === 'imports'), metaData: selectedOptions.some((el) => el === 'metaData'), a4: selectedOptions.some((el) => el === 'a4'), + scaling: pngScalingFactor, }, processes, ); @@ -117,19 +143,35 @@ const ProcessExportModal: React.FC = ({ processes = [], ); const optionSelection = ( - - - {(selectedType ? exportSubOptions[selectedType] : []).map(({ label, value, tooltip }) => ( - - {label} - - ))} - - + + + + {(selectedType ? exportSubOptions[selectedType] : []).map(({ label, value, tooltip }) => ( + + {label} + + ))} + + + {selectedType === 'png' && ( +
+ Scaling: + +
+ )} +
); return ( diff --git a/src/management-system-v2/lib/process-export/PDFPageBuilder.ts b/src/management-system-v2/lib/process-export/PDFPageBuilder.ts index c969fbf1c..e6dc19827 100644 --- a/src/management-system-v2/lib/process-export/PDFPageBuilder.ts +++ b/src/management-system-v2/lib/process-export/PDFPageBuilder.ts @@ -1,5 +1,7 @@ import { jsPDF } from 'jspdf'; +import { getImageDimensions } from './util'; + type ContentPosition = 'left' | 'right' | 'center'; interface ContentInfo { @@ -82,31 +84,6 @@ class PDFPageBuilder { return { width: lineWidth, height: pdf.getLineHeight() }; } - /** - * Returns the dimensions of a vector-image - * - * @param svg the svg string to get the size from - * @returns the width and height of the image - */ - private getImageDimensions(svg: string) { - let width = 0; - let height = 0; - - const viewBox = svg.split('')[0].split('viewBox="'); - - if (viewBox) { - [width, height] = viewBox[1].split('"')[0].split(' ').map(parseFloat).slice(2); - } else { - width = parseFloat(svg.split('width="')[1].split('"')[0]); - height = parseFloat(svg.split('height="')[1].split('"')[0]); - } - - return { - width, - height, - }; - } - /** * Add a vector-image to the page * @@ -134,7 +111,7 @@ class PDFPageBuilder { bottom: margins.bottom || 0, }; - const imageDimensions = this.getImageDimensions(svg); + const imageDimensions = getImageDimensions(svg); // if the image is too small dont scale it up but place it in the middle of the space to occupy if (size && imageDimensions.width < size.width && imageDimensions.height < size.height) { diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index 180b66f22..20309e273 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -20,12 +20,13 @@ import { * The options that can be used to select what should be exported */ export type ProcessExportOptions = { - type: 'bpmn' | 'svg' | 'pdf'; + type: 'bpmn' | 'svg' | 'pdf' | 'png'; artefacts: boolean; // if artefacts like images or user task html should be included in the export subprocesses: boolean; // if collapsed subprocesses should be exported as well (svg, pdf) imports: boolean; // if processes referenced by this process should be exported as well metaData: boolean; // (only pdf) if the process page should contain meta information about the process (name, version, [subprocess-id]) as text a4: boolean; // if an a4 format should be used for the pdf pages (pdf) + scaling: number; // the scaling factor that should be used for png export }; /** diff --git a/src/management-system-v2/lib/process-export/image-export.ts b/src/management-system-v2/lib/process-export/image-export.ts new file mode 100644 index 000000000..7932438d5 --- /dev/null +++ b/src/management-system-v2/lib/process-export/image-export.ts @@ -0,0 +1,233 @@ +import jsZip from 'jszip'; + +import { ProcessesExportData, ProcessExportData } from './export-preparation'; +import { downloadFile, getSVGFromBPMN, getImageDimensions } from './util'; + +/** + * Executes the logic that adds the file for a specific process version/collapsed subprocess + * + * @param processData the data of the complete process + * @param version the specific version to handle + * @param generateBlobFromSvgString function that is used to get the final blob that will be exported from the svg string of the process + * @param filetype the filetype to use for the export file + * @param isImport if the data to be added is part of an imported process + * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) + * @param subprocessId if a specific collapsed subprocess should be added this is the id of the subprocess element + * @param subprocessName the name of the collapsed subprocess to be added + */ +async function addImageFile( + processData: ProcessExportData, + version: string, + generateBlobFromSvgString: (svg: string) => Promise, + filetype: string, + isImport = false, + zipFolder?: jsZip | null, + subprocessId?: string, + subprocessName?: string, +) { + const versionData = processData.versions[version]; + const svg = await getSVGFromBPMN(versionData.bpmn!, subprocessId); + + const blob = await generateBlobFromSvgString(svg); + + let versionName = version; + // if the version data contains an explicit name use that instead of the the current versionName which is just the version id or "latest" + if (versionData.name) { + versionName = versionData.name; + } + if (versionName !== 'latest') versionName = 'version_' + versionName; + + // a) if we output into a zip folder that uses the process name use the version name as the filename + // b) if we output as a single file use the process name as the file name + let filename = zipFolder ? versionName : processData.definitionName; + + // add additional information if this file is added as additional info for another process (only possible in case of zip export) + if (isImport) { + filename = `import_${processData.definitionName || processData.definitionId}_` + filename; + } + if (subprocessId) { + filename += `_subprocess_${subprocessName || subprocessId}`; + } + + if (zipFolder) { + zipFolder.file(`${filename}.${filetype}`, blob); + } else { + downloadFile(`${filename}.${filetype}`, blob); + } +} + +/** + * Allows to recursively add versions of the process and its imports to the folder + * + * @param processesData the data of all processes + * @param processData the data of the complete process + * @param version the specific version to handle + * @param generateBlobFromSvgString function that is used to get the final blob that will be exported from the svg string of the process + * @param filetype the filetype to use for the export file + * @param isImport if the version is of an import + * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) + */ +async function handleProcessVersionExport( + processesData: ProcessesExportData, + processData: ProcessExportData, + version: string, + generateBlobFromSvgString: (svg: string) => Promise, + filetype: string, + isImport = false, + zipFolder?: jsZip | null, +) { + // add the main process (version) file + await addImageFile( + processData, + version, + generateBlobFromSvgString, + filetype, + isImport, + zipFolder, + ); + + const versionData = processData.versions[version]; + // add collapsed subprocesses as additional files + for (const { id: subprocessId, name: subprocessName } of versionData.subprocesses) { + await addImageFile( + processData, + version, + generateBlobFromSvgString, + filetype, + isImport, + zipFolder, + subprocessId, + subprocessName, + ); + } + + // recursively add imports as additional files into the same folder + for (const { definitionId, processVersion } of versionData.imports) { + const importData = processesData.find((el) => el.definitionId === definitionId); + if (importData) { + await handleProcessVersionExport( + processesData, + importData, + processVersion, + generateBlobFromSvgString, + filetype, + true, + zipFolder, + ); + } + } +} + +async function exportImage( + processesData: ProcessesExportData, + processData: ProcessExportData, + generateBlobFromSvgString: (svg: string) => Promise, + filetype: string, + zipFolder?: jsZip | null, +) { + // only export the versions that were explicitly selected for export inside the folder for the given process + const nonImportVersions = Object.entries(processData.versions) + .filter(([_, { isImport }]) => !isImport) + .map(([version]) => version); + + for (const version of nonImportVersions) { + await handleProcessVersionExport( + processesData, + processData, + version, + generateBlobFromSvgString, + filetype, + false, + zipFolder, + ); + } +} + +/** + * Exports a process as a svg either as a single file or into a folder of a zip archive if multiple files should be exported + * + * Might export multiple files if imports or collapsed subprocesses should be exported as well + * + * @param processesData the data of all processes + * @param processData the data of the complete process + * @param zipFolder a zip folder the exported files should be added to in case of multi file export + */ +export async function svgExport( + processesData: ProcessesExportData, + processData: ProcessExportData, + zipFolder?: jsZip | null, +) { + await exportImage( + processesData, + processData, + async (svg) => { + return new Blob([svg], { + type: 'image/svg+xml', + }); + }, + 'svg', + zipFolder, + ); +} + +/** + * Exports a process as a png either as a single file or into a folder of a zip archive if multiple files should be exported + * + * Might export multiple files if imports or collapsed subprocesses should be exported as well + * + * @param processesData the data of all processes + * @param processData the data of the complete process + * @param scaling the scaling factor to use when transforming the process svg to a png + * @param zipFolder a zip folder the exported files should be added to in case of multi file export + */ +export async function pngExport( + processesData: ProcessesExportData, + processData: ProcessExportData, + scaling: number, + zipFolder?: jsZip | null, +) { + await exportImage( + processesData, + processData, + async (svg) => { + const svgBlob = new Blob([svg], { + type: 'image/svg+xml;charset=utf-8', + }); + + const pngBlob: Blob = await new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + + const { width, height } = getImageDimensions(svg); + const image = new Image(width, height); + + image.onload = async () => { + try { + canvas.width = scaling * image.width; + canvas.height = scaling * image.height; + + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + ctx.imageSmoothingEnabled = false; + ctx.scale(scaling, scaling); + ctx.drawImage(image, 0, 0, width, height); + + const uri = canvas.toDataURL('image/png'); + const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/; + if (DATA_URL_REGEX.test(uri)) { + const blob = await fetch(uri).then((res) => res.blob()); + resolve(blob); + } + URL.revokeObjectURL(uri); + } catch (err) { + reject('Failed creating png'); + } + }; + + image.src = URL.createObjectURL(svgBlob); + }); + + return pngBlob; + }, + 'png', + zipFolder, + ); +} diff --git a/src/management-system-v2/lib/process-export/index.ts b/src/management-system-v2/lib/process-export/index.ts index 8c8f9e3cb..a26245ace 100644 --- a/src/management-system-v2/lib/process-export/index.ts +++ b/src/management-system-v2/lib/process-export/index.ts @@ -3,130 +3,15 @@ import { ProcessExportOptions, ExportProcessInfo, ProcessExportData, - ProcessesExportData, } from './export-preparation'; -import { downloadFile, getSVGFromBPMN } from './util'; +import { downloadFile } from './util'; import jsZip from 'jszip'; import 'svg2pdf.js'; import pdfExport from './pdf-export'; - -/** - * Executes the logic that adds the file for a specific process version/collapsed subprocess - * - * @param processData the data of the complete process - * @param version the specific version to handle - * @param isImport if the data to be added is part of an imported process - * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) - * @param subprocessId if a specific collapsed subprocess should be added this is the id of the subprocess element - * @param subprocessName the name of the collapsed subprocess to be added - */ -async function addSVGFile( - processData: ProcessExportData, - version: string, - isImport = false, - zipFolder?: jsZip | null, - subprocessId?: string, - subprocessName?: string, -) { - const versionData = processData.versions[version]; - const svg = await getSVGFromBPMN(versionData.bpmn!, subprocessId); - - const svgBlob = new Blob([svg], { - type: 'image/svg+xml', - }); - - let versionName = version; - // if the version data contains an explicit name use that instead of the the current versionName which is just the version id or "latest" - if (versionData.name) { - versionName = versionData.name; - } - if (versionName !== 'latest') versionName = 'version_' + versionName; - - // a) if we output into a zip folder that uses the process name use the version name as the filename - // b) if we output as a single file use the process name as the file name - let filename = zipFolder ? versionName : processData.definitionName; - - // add additional information if this file is added as additional info for another process (only possible in case of zip export) - if (isImport) { - filename = `import_${processData.definitionName || processData.definitionId}_` + filename; - } - if (subprocessId) { - filename += `_subprocess_${subprocessName || subprocessId}`; - } - - if (zipFolder) { - zipFolder.file(`${filename}.svg`, svgBlob); - } else { - downloadFile(`${filename}.svg`, svgBlob); - } -} - -/** - * Allows to recursively add versions of the process and its imports to the folder - * - * @param processesData the data of all processes - * @param processData the data of the complete process - * @param version the specific version to handle - * @param isImport if the version is of an import - * @param zipFolder the folder to add the svg to (optional since we can export a single file directly as an svg which is decided before this function is called) - */ -async function handleProcessVersionSVGExport( - processesData: ProcessesExportData, - processData: ProcessExportData, - version: string, - isImport = false, - zipFolder?: jsZip | null, -) { - // add the main process (version) file - await addSVGFile(processData, version, isImport, zipFolder); - - const versionData = processData.versions[version]; - // add collapsed subprocesses as additional files - for (const { id: subprocessId, name: subprocessName } of versionData.subprocesses) { - await addSVGFile(processData, version, isImport, zipFolder, subprocessId, subprocessName); - } - - // recursively add imports as additional files into the same folder - for (const { definitionId, processVersion } of versionData.imports) { - const importData = processesData.find((el) => el.definitionId === definitionId); - if (importData) { - await handleProcessVersionSVGExport( - processesData, - importData, - processVersion, - true, - zipFolder, - ); - } - } -} - -/** - * Exports a process as a svg either as a single file or into a folder of a zip archive if multiple files should be exported - * - * Might export multiple files if imports or collapsed subprocesses should be exported as well - * - * @param processesData the data of all processes - * @param processData the data of the complete process - * @param zipFolder a zip folder the exported files should be added to in case of multi file export - */ -async function svgExport( - processesData: ProcessesExportData, - processData: ProcessExportData, - zipFolder?: jsZip | null, -) { - // only export the versions that were explicitly selected for export inside the folder for the given process - const nonImportVersions = Object.entries(processData.versions) - .filter(([_, { isImport }]) => !isImport) - .map(([version]) => version); - - for (const version of nonImportVersions) { - await handleProcessVersionSVGExport(processesData, processData, version, false, zipFolder); - } -} +import { pngExport, svgExport } from './image-export'; async function bpmnExport(processData: ProcessExportData, zipFolder?: jsZip | null) { for (let [versionName, versionData] of Object.entries(processData.versions)) { @@ -212,6 +97,10 @@ export async function exportProcesses(options: ProcessExportOptions, processes: const folder = zip?.folder(processData.definitionName); await svgExport(exportData, processData, folder); } + if (options.type === 'png' && !processData.isImport) { + const folder = zip?.folder(processData.definitionName); + await pngExport(exportData, processData, options.scaling, folder); + } } } diff --git a/src/management-system-v2/lib/process-export/util.ts b/src/management-system-v2/lib/process-export/util.ts index 78c98c049..d148f6126 100644 --- a/src/management-system-v2/lib/process-export/util.ts +++ b/src/management-system-v2/lib/process-export/util.ts @@ -59,3 +59,28 @@ export async function getSVGFromBPMN(bpmn: string, subprocessId?: string) { return svg; } + +/** + * Returns the dimensions of a vector-image + * + * @param svg the svg string to get the size from + * @returns the width and height of the image + */ +export function getImageDimensions(svg: string) { + let width = 0; + let height = 0; + + const viewBox = svg.split('')[0].split('viewBox="'); + + if (viewBox) { + [width, height] = viewBox[1].split('"')[0].split(' ').map(parseFloat).slice(2); + } else { + width = parseFloat(svg.split('width="')[1].split('"')[0]); + height = parseFloat(svg.split('height="')[1].split('"')[0]); + } + + return { + width, + height, + }; +} From ac6c0fcb28d2a24711fd197d584f0098f0499a98 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 9 Nov 2023 17:09:03 +0100 Subject: [PATCH 02/10] Trying to handle processes for which an upscaling factor of 10 would exceed the maximum image size that can be handled --- .../components/process-export.tsx | 6 +-- .../lib/helpers/javascriptHelpers.ts | 13 +++-- .../lib/process-export/export-preparation.ts | 30 ++++++++++- .../lib/process-export/image-export.ts | 54 ++++++++++++------- .../lib/process-export/util.ts | 2 + 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index aa77c1695..98d98e065 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -96,7 +96,6 @@ const ProcessExportModal: React.FC = ({ processes = [], const [selectedType, setSelectedType] = useState(); const [selectedOptions, setSelectedOptions] = useState(['metaData']); const [isExporting, setIsExporting] = useState(false); - const [maxPngScalingFactor, setMaxPngScalingFactor] = useState(10); const [pngScalingFactor, setPngScalingFactor] = useState(1); const handleTypeSelectionChange = ({ target: { value } }: RadioChangeEvent) => { @@ -163,11 +162,12 @@ const ProcessExportModal: React.FC = ({ processes = [], )} diff --git a/src/management-system-v2/lib/helpers/javascriptHelpers.ts b/src/management-system-v2/lib/helpers/javascriptHelpers.ts index dc62d363c..f2bef88e3 100644 --- a/src/management-system-v2/lib/helpers/javascriptHelpers.ts +++ b/src/management-system-v2/lib/helpers/javascriptHelpers.ts @@ -1,4 +1,7 @@ -export async function asyncMap(array: Array, cb: (entry: any, index: Number) => Promise) { +export async function asyncMap( + array: Array, + cb: (entry: Type, index: Number) => Promise, +) { const mappingCallbacks = array.map(async (entry, index) => await cb(entry, index)); const mappedValues = await Promise.all(mappingCallbacks); @@ -6,14 +9,14 @@ export async function asyncMap(array: Array, cb: (entry: any, index: Number return mappedValues; } -export async function asyncForEach( - array: Array, - cb: (entry: any, index: Number) => Promise, +export async function asyncForEach( + array: Array, + cb: (entry: Type, index: Number) => Promise, ) { await asyncMap(array, cb); } -export async function asyncFilter(array: Array, cb: (entry: any) => Promise) { +export async function asyncFilter(array: Array, cb: (entry: Type) => Promise) { // map the elements to their value or undefined and then filter undefined entries return ( await asyncMap(array, async (entry) => { diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index 20309e273..d5686a96d 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -16,6 +16,9 @@ import { getDefinitionsAndProcessIdForEveryCallActivity, } from '@proceed/bpmn-helper'; +import { asyncForEach, asyncMap } from '../helpers/javascriptHelpers'; +import { getImageDimensions, getSVGFromBPMN } from './util'; + /** * The options that can be used to select what should be exported */ @@ -156,6 +159,25 @@ function getVersionName(version?: string | number) { return version ? `${version}` : 'latest'; } +async function getMaximumScalingFactor(exportData: ProcessesExportData) { + console.log(exportData); + + const allVersionBpmns = exportData.flatMap(({ versions }) => + Object.values(versions).map(({ bpmn }) => bpmn), + ); + + const maximums = await asyncMap(allVersionBpmns, async (bpmn) => { + const svg = await getSVGFromBPMN(bpmn); + const diagramSize = getImageDimensions(svg); + + return Math.floor(Math.sqrt(268400000 / (diagramSize.width * diagramSize.height))); + }); + + console.log(maximums); + + return Math.min(...maximums, 10); +} + /** * Will fetch information for a process (version) from the backend if it is not present in the exportData yet * @@ -327,8 +349,14 @@ export async function prepareExport( } } - return Object.entries(exportData).map(([definitionId, data]) => ({ + const finalExportData = Object.entries(exportData).map(([definitionId, data]) => ({ ...data, definitionId, })); + + if (options.type === 'png') { + options.scaling = (options.scaling / 10) * (await getMaximumScalingFactor(finalExportData)); + } + + return finalExportData; } diff --git a/src/management-system-v2/lib/process-export/image-export.ts b/src/management-system-v2/lib/process-export/image-export.ts index 7932438d5..22ed2b892 100644 --- a/src/management-system-v2/lib/process-export/image-export.ts +++ b/src/management-system-v2/lib/process-export/image-export.ts @@ -195,34 +195,50 @@ export async function pngExport( }); const pngBlob: Blob = await new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - const { width, height } = getImageDimensions(svg); const image = new Image(width, height); image.onload = async () => { - try { - canvas.width = scaling * image.width; - canvas.height = scaling * image.height; - - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - ctx.imageSmoothingEnabled = false; - ctx.scale(scaling, scaling); - ctx.drawImage(image, 0, 0, width, height); - - const uri = canvas.toDataURL('image/png'); - const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/; - if (DATA_URL_REGEX.test(uri)) { - const blob = await fetch(uri).then((res) => res.blob()); - resolve(blob); + // decrease the scaling if the image cannot be exported + for (let scale = scaling; scale >= 1; scale -= 1) { + try { + const canvas = document.createElement('canvas'); + canvas.width = scale * image.width; + canvas.height = scale * image.height; + + //Creating 2D Canvas + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + //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 + const uri = canvas.toDataURL('image/png'); + const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/; + if (DATA_URL_REGEX.test(uri)) { + const blob = await fetch(uri).then((res) => res.blob()); + resolve(blob); + URL.revokeObjectURL(uri); + return; + } else { + //Release Object URL, so the browser doesn't keep reference + URL.revokeObjectURL(uri); + } + } catch (err) { + reject(err); } - URL.revokeObjectURL(uri); - } catch (err) { - reject('Failed creating png'); } + + reject('Cannot create a png. The process size is possibly too large.'); }; + //Takes BLOB, File and Media Source and returns object url image.src = URL.createObjectURL(svgBlob); + image.remove(); }); return pngBlob; diff --git a/src/management-system-v2/lib/process-export/util.ts b/src/management-system-v2/lib/process-export/util.ts index d148f6126..dd384b549 100644 --- a/src/management-system-v2/lib/process-export/util.ts +++ b/src/management-system-v2/lib/process-export/util.ts @@ -57,6 +57,8 @@ export async function getSVGFromBPMN(bpmn: string, subprocessId?: string) { const { svg } = await viewer.saveSVG(); + document.body.removeChild(viewerElement); + return svg; } From 4e7727302dd740a184b050502ce6b9dccd121bdd Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Fri, 10 Nov 2023 00:37:47 +0100 Subject: [PATCH 03/10] Added functionality to copy a process as a png from the modeler --- .../components/modeler.tsx | 17 +++ .../lib/process-export/copy-process-image.ts | 50 ++++++++ .../lib/process-export/export-preparation.ts | 4 - .../lib/process-export/image-export.ts | 110 +++++++++--------- 4 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 src/management-system-v2/lib/process-export/copy-process-image.ts diff --git a/src/management-system-v2/components/modeler.tsx b/src/management-system-v2/components/modeler.tsx index 6072e48ba..d2ced71aa 100644 --- a/src/management-system-v2/components/modeler.tsx +++ b/src/management-system-v2/components/modeler.tsx @@ -16,6 +16,8 @@ import { usePutAsset } from '@/lib/fetch-data'; import { useProcessBpmn } from '@/lib/process-queries'; import VersionToolbar from './version-toolbar'; +import { copyProcessImage } from '@/lib/process-export/copy-process-image'; + // Conditionally load the BPMN modeler only on the client, because it uses // "window" reference. It won't be included in the initial bundle, but will be // immediately loaded when the initial script first executes (not after @@ -94,6 +96,21 @@ const Modeler: FC = ({ minimized, ...props }) => { }); } + // allow keyboard shortcuts like copy (strg+c) and paste (strg+v) etc. + (modeler.current.get('keyboard') as any).bind(document); + + // create a custom copy behaviour where the whole process or selected parts can be copied to the clipboard as an image + (modeler.current.get('keyboard') as any).addListener( + async (_: any, events: { keyEvent: KeyboardEvent }) => { + const { keyEvent } = events; + // handle the copy shortcut + if (keyEvent.ctrlKey && keyEvent.key === 'c' && modeler.current) { + await copyProcessImage(modeler.current); + } + }, + 'keyboard.keyup', + ); + setModeler(modeler.current); setInitialized(true); }); diff --git a/src/management-system-v2/lib/process-export/copy-process-image.ts b/src/management-system-v2/lib/process-export/copy-process-image.ts new file mode 100644 index 000000000..8f5e2f319 --- /dev/null +++ b/src/management-system-v2/lib/process-export/copy-process-image.ts @@ -0,0 +1,50 @@ +import { toBpmnObject, toBpmnXml } from '@proceed/bpmn-helper'; + +import { getSVGFromBPMN } from './util'; +import { getPNGFromSVG } from './image-export'; + +/** + * Adds an image of a process or selected parts of a process to the clipboard + * + * @param modeler the modeler to copy the process image from + */ +export async function copyProcessImage(modeler: any) { + let { xml } = await modeler.saveXML({ format: true }); + + // get the currently visible layer + const rootElement = (modeler.get('canvas') as any).getRootElement().businessObject; + const subprocessId = + rootElement.$type === 'bpmn:Process' || rootElement.$type === 'bpmn:Collaboration' + ? undefined + : rootElement.id; + + // get the selected elements + let selection: any[] = (modeler.get('selection') as any).get(); + // remove connections where the source or target or both are not selected + selection = selection.filter((el) => { + if (!el.source && !el.target) return true; + return selection.includes(el.source) && selection.includes(el.target); + }); + + // if something is selected only copy the selection + if (selection.length) { + const bpmnObj: any = await toBpmnObject(xml!); + // find the correct plane (either the root process/collaboration or a subprocess) + const plane = bpmnObj.diagrams.find( + (el: any) => el.plane.bpmnElement.id === rootElement.id, + ).plane; + // remove the visualisation of the elements that are not selected + plane.planeElement = plane.planeElement.filter((diEl: any) => + selection.some((el: any) => el.id === diEl.bpmnElement.id), + ); + xml = await toBpmnXml(bpmnObj); + } + + // get the png and copy it to the clipboard + const svg = await getSVGFromBPMN(xml!, subprocessId); + const blob = await getPNGFromSVG(svg); + const data = [new ClipboardItem({ 'image/png': blob })]; + navigator.clipboard.write(data).then(() => { + console.log('Copied to clipboard'); + }); +} diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index d5686a96d..c5aa2e8af 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -160,8 +160,6 @@ function getVersionName(version?: string | number) { } async function getMaximumScalingFactor(exportData: ProcessesExportData) { - console.log(exportData); - const allVersionBpmns = exportData.flatMap(({ versions }) => Object.values(versions).map(({ bpmn }) => bpmn), ); @@ -173,8 +171,6 @@ async function getMaximumScalingFactor(exportData: ProcessesExportData) { return Math.floor(Math.sqrt(268400000 / (diagramSize.width * diagramSize.height))); }); - console.log(maximums); - return Math.min(...maximums, 10); } diff --git a/src/management-system-v2/lib/process-export/image-export.ts b/src/management-system-v2/lib/process-export/image-export.ts index 22ed2b892..cc5c3006e 100644 --- a/src/management-system-v2/lib/process-export/image-export.ts +++ b/src/management-system-v2/lib/process-export/image-export.ts @@ -170,6 +170,61 @@ export async function svgExport( ); } +export async function getPNGFromSVG(svg: string, scaling = 1) { + const svgBlob = new Blob([svg], { + type: 'image/svg+xml;charset=utf-8', + }); + + const pngBlob: Blob = await new Promise((resolve, reject) => { + const { width, height } = getImageDimensions(svg); + const image = new Image(width, height); + + image.onload = async () => { + // decrease the scaling if the image cannot be exported + for (let scale = scaling; scale >= 1; scale -= 1) { + try { + const canvas = document.createElement('canvas'); + canvas.width = scale * image.width; + canvas.height = scale * image.height; + + //Creating 2D Canvas + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + //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 + const uri = canvas.toDataURL('image/png'); + const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/; + if (DATA_URL_REGEX.test(uri)) { + const blob = await fetch(uri).then((res) => res.blob()); + resolve(blob); + URL.revokeObjectURL(uri); + return; + } else { + //Release Object URL, so the browser doesn't keep reference + URL.revokeObjectURL(uri); + } + } catch (err) { + reject(err); + } + } + + reject('Cannot create a png. The process size is possibly too large.'); + }; + + //Takes BLOB, File and Media Source and returns object url + image.src = URL.createObjectURL(svgBlob); + image.remove(); + }); + + return pngBlob; +} + /** * Exports a process as a png either as a single file or into a folder of a zip archive if multiple files should be exported * @@ -189,60 +244,7 @@ export async function pngExport( await exportImage( processesData, processData, - async (svg) => { - const svgBlob = new Blob([svg], { - type: 'image/svg+xml;charset=utf-8', - }); - - const pngBlob: Blob = await new Promise((resolve, reject) => { - const { width, height } = getImageDimensions(svg); - const image = new Image(width, height); - - image.onload = async () => { - // decrease the scaling if the image cannot be exported - for (let scale = scaling; scale >= 1; scale -= 1) { - try { - const canvas = document.createElement('canvas'); - canvas.width = scale * image.width; - canvas.height = scale * image.height; - - //Creating 2D Canvas - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - //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 - const uri = canvas.toDataURL('image/png'); - const DATA_URL_REGEX = /^data:((?:\w+\/(?:(?!;).)+)?)((?:;[\w\W]*?[^;])*),(.+)$/; - if (DATA_URL_REGEX.test(uri)) { - const blob = await fetch(uri).then((res) => res.blob()); - resolve(blob); - URL.revokeObjectURL(uri); - return; - } else { - //Release Object URL, so the browser doesn't keep reference - URL.revokeObjectURL(uri); - } - } catch (err) { - reject(err); - } - } - - reject('Cannot create a png. The process size is possibly too large.'); - }; - - //Takes BLOB, File and Media Source and returns object url - image.src = URL.createObjectURL(svgBlob); - image.remove(); - }); - - return pngBlob; - }, + (svg: string) => getPNGFromSVG(svg, scaling), 'png', zipFolder, ); From 12d0472ef5d764a5066bc9509e649cb6b6474f1a Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 15 Nov 2023 12:42:12 +0100 Subject: [PATCH 04/10] Increased the quality of the image that is copied to clipboard --- .../lib/process-export/copy-process-image.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/management-system-v2/lib/process-export/copy-process-image.ts b/src/management-system-v2/lib/process-export/copy-process-image.ts index 8f5e2f319..3bb53c252 100644 --- a/src/management-system-v2/lib/process-export/copy-process-image.ts +++ b/src/management-system-v2/lib/process-export/copy-process-image.ts @@ -42,7 +42,7 @@ export async function copyProcessImage(modeler: any) { // get the png and copy it to the clipboard const svg = await getSVGFromBPMN(xml!, subprocessId); - const blob = await getPNGFromSVG(svg); + const blob = await getPNGFromSVG(svg, 3); const data = [new ClipboardItem({ 'image/png': blob })]; navigator.clipboard.write(data).then(() => { console.log('Copied to clipboard'); From 508e0f5a8b8ab75dfbcf2ebd67aff8df4ad40215 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 15 Nov 2023 17:52:37 +0100 Subject: [PATCH 05/10] Changed the scaling selection for images --- .../components/process-export.tsx | 18 ++++++++---------- .../lib/process-export/export-preparation.ts | 5 +++-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 98d98e065..151836c7c 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -158,17 +158,15 @@ const ProcessExportModal: React.FC = ({ processes = [], {selectedType === 'png' && (
- Scaling: - Quality: + setPngScalingFactor(e.target.value)} value={pngScalingFactor} - min={1} - max={10} - step={1} - marks={{ 1: 'Min', 10: 'Max' }} - dots - onChange={setPngScalingFactor} - tooltip={{ formatter: null }} - /> + > + Normal + Good + Excellent +
)} diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index c5aa2e8af..5860b38e8 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -167,7 +167,7 @@ async function getMaximumScalingFactor(exportData: ProcessesExportData) { const maximums = await asyncMap(allVersionBpmns, async (bpmn) => { const svg = await getSVGFromBPMN(bpmn); const diagramSize = getImageDimensions(svg); - + // the canvas that is used to transform the svg to a png has a limited size (https://github.com/jhildenbiddle/canvas-size#test-results) return Math.floor(Math.sqrt(268400000 / (diagramSize.width * diagramSize.height))); }); @@ -351,7 +351,8 @@ export async function prepareExport( })); if (options.type === 'png') { - options.scaling = (options.scaling / 10) * (await getMaximumScalingFactor(finalExportData)); + // decrease the scaling factor if the image size would exceed export limits + options.scaling = Math.min(options.scaling, await getMaximumScalingFactor(finalExportData)); } return finalExportData; From 50b696ded6173257f1a5cb26266afb2e1ff04806 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 16 Nov 2023 18:27:32 +0100 Subject: [PATCH 06/10] Increase the size of the export modal slightly to allow quality options to be displayed side by side if there is enough space --- src/management-system-v2/components/process-export.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 151836c7c..4465510d8 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -181,6 +181,7 @@ const ProcessExportModal: React.FC = ({ processes = [], onCancel={handleClose} centered okButtonProps={{ disabled: !selectedType, loading: isExporting }} + width={540} > {typeSelection} From 99917eab16505af5d4e8038dba60c92e11457918 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 16 Nov 2023 18:30:09 +0100 Subject: [PATCH 07/10] Autoselect one of the quality options for png export --- src/management-system-v2/components/process-export.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 4465510d8..62e3010ba 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -96,7 +96,7 @@ const ProcessExportModal: React.FC = ({ processes = [], const [selectedType, setSelectedType] = useState(); const [selectedOptions, setSelectedOptions] = useState(['metaData']); const [isExporting, setIsExporting] = useState(false); - const [pngScalingFactor, setPngScalingFactor] = useState(1); + const [pngScalingFactor, setPngScalingFactor] = useState(1.5); const handleTypeSelectionChange = ({ target: { value } }: RadioChangeEvent) => { setSelectedType(value); From a3c3e6854724a28e6d212d0659dc8c9cc1de8065 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 16 Nov 2023 20:02:14 +0100 Subject: [PATCH 08/10] Moved the filtering of elements to include in image export into the util svg creation function --- src/helper-modules/bpmn-helper/src/util.js | 1 + .../lib/process-export/copy-process-image.ts | 26 +++---------- .../lib/process-export/util.ts | 38 ++++++++++++++++++- 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/helper-modules/bpmn-helper/src/util.js b/src/helper-modules/bpmn-helper/src/util.js index 7be220236..db81b593b 100644 --- a/src/helper-modules/bpmn-helper/src/util.js +++ b/src/helper-modules/bpmn-helper/src/util.js @@ -58,6 +58,7 @@ function getChildren(travObj) { 'diagrams', 'imports', 'extensionElements', + 'participants', ]; const allChildren = childNodeTypes diff --git a/src/management-system-v2/lib/process-export/copy-process-image.ts b/src/management-system-v2/lib/process-export/copy-process-image.ts index 3bb53c252..a58daa473 100644 --- a/src/management-system-v2/lib/process-export/copy-process-image.ts +++ b/src/management-system-v2/lib/process-export/copy-process-image.ts @@ -20,28 +20,12 @@ export async function copyProcessImage(modeler: any) { // get the selected elements let selection: any[] = (modeler.get('selection') as any).get(); - // remove connections where the source or target or both are not selected - selection = selection.filter((el) => { - if (!el.source && !el.target) return true; - return selection.includes(el.source) && selection.includes(el.target); - }); - - // if something is selected only copy the selection - if (selection.length) { - const bpmnObj: any = await toBpmnObject(xml!); - // find the correct plane (either the root process/collaboration or a subprocess) - const plane = bpmnObj.diagrams.find( - (el: any) => el.plane.bpmnElement.id === rootElement.id, - ).plane; - // remove the visualisation of the elements that are not selected - plane.planeElement = plane.planeElement.filter((diEl: any) => - selection.some((el: any) => el.id === diEl.bpmnElement.id), - ); - xml = await toBpmnXml(bpmnObj); - } - // get the png and copy it to the clipboard - const svg = await getSVGFromBPMN(xml!, subprocessId); + const svg = await getSVGFromBPMN( + xml!, + subprocessId, + selection.map((el) => el.id), + ); const blob = await getPNGFromSVG(svg, 3); const data = [new ClipboardItem({ 'image/png': blob })]; navigator.clipboard.write(data).then(() => { diff --git a/src/management-system-v2/lib/process-export/util.ts b/src/management-system-v2/lib/process-export/util.ts index dd384b549..d0bdb7574 100644 --- a/src/management-system-v2/lib/process-export/util.ts +++ b/src/management-system-v2/lib/process-export/util.ts @@ -1,5 +1,7 @@ import { v4 } from 'uuid'; +import { toBpmnObject, toBpmnXml, getElementById } from '@proceed/bpmn-helper'; + /** * Downloads the data onto the device of the user * @@ -32,7 +34,11 @@ export function downloadFile(filename: string, data: Blob) { * @param bpmn * @returns the svg image as a string */ -export async function getSVGFromBPMN(bpmn: string, subprocessId?: string) { +export async function getSVGFromBPMN( + bpmn: string, + subprocessId?: string, + flowElementsToExportIds: string[] = [], +) { const Viewer = (await import('bpmn-js/lib/Viewer')).default; //Creating temporary element for BPMN Viewer @@ -42,6 +48,36 @@ export async function getSVGFromBPMN(bpmn: string, subprocessId?: string) { viewerElement.id = 'canvas_' + v4(); document.body.appendChild(viewerElement); + // if specific elements are selected for export make sure to filter out all other elements + if (flowElementsToExportIds.length) { + const bpmnObj: any = await toBpmnObject(bpmn!); + // remove connections where the source or target or both are not selected + flowElementsToExportIds = flowElementsToExportIds.filter((id) => { + const el = getElementById(bpmnObj, id) as any; + return ( + el && + (!el.sourceRef || flowElementsToExportIds.includes(el.sourceRef.id)) && + (!el.targetRef || flowElementsToExportIds.includes(el.targetRef.id)) + ); + }); + + if (flowElementsToExportIds.length) { + // find the correct plane (either the root process/collaboration or a subprocess) + const { plane } = bpmnObj.diagrams.find((el: any) => + subprocessId + ? // either find the subprocess plane + el.plane.bpmnElement.id === subprocessId + : // or the root process/collaboration plane + el.plane.bpmnElement.$type !== 'bpmn:SubProcess', + ); + // remove the visualisation of the elements that are not selected + plane.planeElement = plane.planeElement.filter((diEl: any) => + flowElementsToExportIds.some((id) => id === diEl.bpmnElement.id), + ); + bpmn = await toBpmnXml(bpmnObj); + } + } + //Create a viewer to transform the bpmn into an svg const viewer = new Viewer({ container: '#' + viewerElement.id }); await viewer.importXML(bpmn); From 560e96ce4eb5fee62d9b94b1c873068853aa6749 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 16 Nov 2023 20:12:59 +0100 Subject: [PATCH 09/10] Added tooltips for png quality option --- .../components/process-export.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/management-system-v2/components/process-export.tsx b/src/management-system-v2/components/process-export.tsx index 62e3010ba..9aaf4c8f2 100644 --- a/src/management-system-v2/components/process-export.tsx +++ b/src/management-system-v2/components/process-export.tsx @@ -158,14 +158,23 @@ const ProcessExportModal: React.FC = ({ processes = [], {selectedType === 'png' && (
- Quality: + + Quality: + + setPngScalingFactor(e.target.value)} value={pngScalingFactor} > - Normal - Good - Excellent + + Normal + + + Good + + + Excellent +
)} From 36ec182d00b840a729a88863ff322df604891de2 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Thu, 16 Nov 2023 20:17:07 +0100 Subject: [PATCH 10/10] Removed a hard coded scaling maximum --- .../lib/process-export/export-preparation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/management-system-v2/lib/process-export/export-preparation.ts b/src/management-system-v2/lib/process-export/export-preparation.ts index 5860b38e8..ad3a2466b 100644 --- a/src/management-system-v2/lib/process-export/export-preparation.ts +++ b/src/management-system-v2/lib/process-export/export-preparation.ts @@ -171,7 +171,7 @@ async function getMaximumScalingFactor(exportData: ProcessesExportData) { return Math.floor(Math.sqrt(268400000 / (diagramSize.width * diagramSize.height))); }); - return Math.min(...maximums, 10); + return Math.min(...maximums); } /**