import { uniqueId } from 'lodash' ; import { DiagramModel } from '../diagram/model' ; import { Rect , Vector , boundsOf } from '../diagram/geometry' ; const canvg = require ( 'canvg-fixed' ) ; const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' ; export interface ToSVGOptions { model : DiagramModel ; paper : SVGSVGElement ; contentBox : Rect ; getOverlayedElement : ( id : string ) => HTMLElement ; preserveDimensions ? : boolean ; convertImagesToDataUris ? : boolean ; blacklistedCssAttributes ? : string [ ] ; elementsToRemoveSelector ? : string ; mockImages ? : boolean ; watermarkSvg ? : string ; } interface Bounds { width : number ; height : number ; } /**
 * Padding (in px) for <foreignObject> elements of exported SVG to
 * mitigate issues with elements body overflow caused by missing styles
 * in exported image.
 */ const FOREIGN_OBJECT_SIZE_PADDING = 2 ; const BORDER_PADDING = 100 ; export function toSVG ( options ) { return exportSVG ( options ) . then ( ( svg ) => new XMLSerializer ( ) . serializeToString ( svg ) ) ; } function exportSVG ( options ) { const { contentBox : bbox , watermarkSvg } = options ; const { svgClone , imageBounds } = clonePaperSvg ( options , FOREIGN_OBJECT_SIZE_PADDING ) ; const paddedWidth = bbox . width + 2 * BORDER_PADDING ; const paddedHeight = bbox . height + 2 * BORDER_PADDING ; if ( options . preserveDimensions ) { svgClone . setAttribute ( 'width' , paddedWidth . toString ( ) ) ; svgClone . setAttribute ( 'height' , paddedHeight . toString ( ) ) ; } else { svgClone . setAttribute ( 'width' , '100%' ) ; svgClone . setAttribute ( 'height' , '100%' ) ; } const viewBox = { x : bbox . x - BORDER_PADDING , y : bbox . y - BORDER_PADDING , width : paddedWidth , height : paddedHeight , } ; svgClone . setAttribute ( 'viewBox' , `${ viewBox . x } ${ viewBox . y } ${ viewBox . width } ${ viewBox . height }` ) ; addWatermark ( svgClone , viewBox , watermarkSvg ) ; const images = [ ] ; const nodes = svgClone . querySelectorAll ( 'img' ) ; foreachNode ( nodes , ( node ) => images . push ( node ) ) ; const convertingImages = Promise . all ( images . map ( ( img ) => { const exportKey = img . getAttribute ( 'export-key' ) ; img . removeAttribute ( 'export-key' ) ; if ( exportKey ) { const { width , height } = imageBounds [ exportKey ] ; img . setAttribute ( 'width' , width . toString ( ) ) ; img . setAttribute ( 'height' , height . toString ( ) ) ; if ( ! options . convertImagesToDataUris ) { return Promise . resolve ( ) ; } return exportAsDataUri ( img ) . then ( ( dataUri ) => { if ( dataUri && dataUri !== 'data:image/svg+xml,' ) { img . src = dataUri ; } } ) . catch ( ( err ) => { console . warn ( 'Failed to export image: ' + img . src , err ) ; } ) ; } else { return Promise . resolve ( ) ; } } ) ) ; return convertingImages . then ( ( ) => { const exportedCssText = extractCSSFromDocument ( svgClone ) ; const defs = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'defs' ) ; defs . innerHTML = `<style>${ exportedCssText }</style>` ; svgClone . insertBefore ( defs , svgClone . firstChild ) ; if ( options . elementsToRemoveSelector ) { foreachNode ( svgClone . querySelectorAll ( options . elementsToRemoveSelector ) , ( node ) => node . remove ( ) ) ; } return svgClone ; } ) ; } function addWatermark ( svg , viewBox , watermarkSvg ) { const WATERMARK_CLASS = 'graph-explorer-exported-watermark' ; const WATERMARK_MAX_WIDTH = 120 ; const WATERMARK_PADDING = 20 ; const image = document . createElementNS ( SVG_NAMESPACE , 'image' ) ; image . setAttributeNS ( 'http://www.w3.org/1999/xlink' , 'href' , watermarkSvg ) ; image . setAttribute ( 'class' , WATERMARK_CLASS ) ; const width = Math . min ( viewBox . width * 0.2 , WATERMARK_MAX_WIDTH ) ; const x = viewBox . x + viewBox . width - width - WATERMARK_PADDING ; const y = viewBox . y + WATERMARK_PADDING ; image . setAttribute ( 'x' , x . toString ( ) ) ; image . setAttribute ( 'y' , y . toString ( ) ) ; image . setAttribute ( 'width' , width . toString ( ) ) ; image . setAttribute ( 'opacity' , '0.3' ) ; svg . insertBefore ( image , svg . firstChild ) ; } function clearAttributes ( svg ) { const availableIds = { } ; const prohibitedIds = { } ; const defss = svg . querySelectorAll ( 'defs' ) ; foreachNode ( defss , ( defs ) => { foreachNode ( defs . childNodes , ( def ) => { const id = ( def as SVGElement ) . getAttribute ( 'id' ) ; if ( id ) { availableIds [ id ] = true ; } } ) ; } ) ; const paths = svg . querySelectorAll ( '*' ) ; foreachNode ( paths , ( path ) => { const markerStart = extractId ( path . getAttribute ( 'marker-start' ) ) ; if ( markerStart && ! availableIds [ markerStart ] ) { path . removeAttribute ( 'marker-start' ) ; } const markerEnd = extractId ( path . getAttribute ( 'marker-end' ) ) ; if ( markerEnd && ! availableIds [ markerEnd ] ) { path . removeAttribute ( 'marker-end' ) ; } const filterId = extractId ( path . getAttribute ( 'filter' ) ) ; if ( filterId && ! availableIds [ filterId ] ) { path . removeAttribute ( 'filter' ) ; } } ) ; function extractId ( attributeValue ) { if ( attributeValue ) { return ( attributeValue . match ( /#(.*?)\)/ ) || [ ] ) [ 1 ] ; } return undefined ; } } function extractCSSFromDocument ( targetSubtree ) { const exportedRules = new Set < CSSStyleRule > ( ) ; for ( let i = 0 ; i < document . styleSheets . length ; i ++ ) { let rules ; try { const cssSheet = document . styleSheets [ i ] as CSSStyleSheet ; rules = cssSheet . cssRules || cssSheet . rules ; if ( ! rules ) { continue ; } } catch ( e ) { continue ; } for ( let j = 0 ; j < rules . length ; j ++ ) { const rule = rules [ j ] ; if ( rule instanceof CSSStyleRule ) { if ( targetSubtree . querySelector ( rule . selectorText ) ) { exportedRules . add ( rule ) ; } } } } const exportedCssTexts = [ ] ; exportedRules . forEach ( ( rule ) => exportedCssTexts . push ( rule . cssText ) ) ; return exportedCssTexts . join ( '\n' ) ; } function clonePaperSvg ( options , elementSizePadding ) { const { model , paper , getOverlayedElement } = options ; const svgClone = paper . cloneNode ( true ) as SVGSVGElement ; svgClone . removeAttribute ( 'class' ) ; svgClone . removeAttribute ( 'style' ) ; function findViewport ( ) { let child = svgClone . firstChild ; while ( child ) { if ( child instanceof SVGGElement ) { return child ; } child = child . nextSibling ; } return undefined ; } const viewport = findViewport ( ) ; viewport . removeAttribute ( 'transform' ) ; const imageBounds = { } ; for ( const element of model . elements ) { const modelId = element . id ; const overlayedView = getOverlayedElement ( modelId ) ; if ( ! overlayedView ) { continue ; } const elementRoot = document . createElementNS ( SVG_NAMESPACE , 'g' ) ; const overlayedViewContent = overlayedView . firstChild . cloneNode ( true ) as HTMLElement ; elementRoot . setAttribute ( 'class' , 'graph-explorer-exported-element' ) ; const newRoot = document . createElementNS ( SVG_NAMESPACE , 'foreignObject' ) ; newRoot . appendChild ( overlayedViewContent ) ; const { x , y , width , height } = boundsOf ( element ) ; newRoot . setAttribute ( 'transform' , `translate(${ x },${ y })` ) ; newRoot . setAttribute ( 'width' , ( width + elementSizePadding ) . toString ( ) ) ; newRoot . setAttribute ( 'height' , ( height + elementSizePadding ) . toString ( ) ) ; elementRoot . appendChild ( newRoot ) ; viewport . appendChild ( elementRoot ) ; const clonedNodes = overlayedViewContent . querySelectorAll ( 'img' ) ; foreachNode ( overlayedView . querySelectorAll ( 'img' ) , ( img , index ) => { const exportKey = uniqueId ( 'export-key-' ) ; clonedNodes [ index ] . setAttribute ( 'export-key' , exportKey ) ; imageBounds [ exportKey ] = { width : img . clientWidth , height : img . clientHeight , } ; } ) ; } return { svgClone , imageBounds } ; } function exportAsDataUri ( original ) { const url = original . src ; if ( ! url || url . startsWith ( 'data:' ) ) { return Promise . resolve ( url ) ; } return loadCrossOriginImage ( original . src ) . then ( ( image ) => { const canvas = document . createElement ( 'canvas' ) ; canvas . width = image . width ; canvas . height = image . height ; const context = canvas . getContext ( '2d' ) ; context . drawImage ( image , 0 , 0 ) ; const extensionMatch = url . match ( /\.([a-zA-Z0-9]+)[^.a-zA-Z0-9]?[^.]*$/ ) ; const extension = extensionMatch ? extensionMatch [ 1 ] . toLowerCase ( ) : 'png' ; try { const mimeType = 'image/' + ( extension === 'jpg' ? 'jpeg' : extension ) ; const dataUri = canvas . toDataURL ( mimeType ) ; return Promise . resolve ( dataUri ) ; } catch ( e ) { if ( extension !== 'svg' ) { return Promise . reject ( 'Failed to convert image to data URI' ) ; } return fetch ( url ) . then ( ( response ) => response . text ( ) ) . then ( ( svg ) => svg . length > 0 ? 'data:image/svg+xml,' + encodeURIComponent ( svg ) : '' ) ; } } ) ; } function loadCrossOriginImage ( src ) { const image = new Image ( ) ; image . crossOrigin = 'anonymous' ; const promise = new Promise < HTMLImageElement > ( ( resolve , reject ) => { image . onload = ( ) => resolve ( image ) ; image . onerror = ( ev ) => reject ( ev ) ; } ) ; image . src = src ; return promise ; } function foreachNode < T extends Node > ( nodeList , callback ) { for ( let i = 0 ; i < nodeList . length ; i ++ ) { callback ( nodeList [ i ] , i ) ; } } export interface ToDataURLOptions { /** 'image/png' | 'image/jpeg' | ... */ mimeType ? : string ; width ? : number ; height ? : number ; /** Background color, transparent by default. */ backgroundColor ? : string ; quality ? : number ; } const MAX_CANVAS_LENGTH = 4096 ; export async function toDataURL ( options ) { const { paper , mimeType = 'image/png' } = options ; const svgOptions = { ... options , convertImagesToDataUris : true , mockImages : false , preserveDimensions : true , } ; const svg = await exportSVG ( svgOptions ) ; const svgBox = { width : Number ( svg . getAttribute ( 'width' ) ) , height : Number ( svg . getAttribute ( 'height' ) ) , } ; const containerSize = typeof options . width === 'number' || typeof options . height === 'number' ? { width : options . width , height : options . height } : fallbackContainerSize ( svgBox ) ; const { innerSize , outerSize , offset } = computeAutofit ( svgBox , containerSize ) ; svg . setAttribute ( 'width' , innerSize . width . toString ( ) ) ; svg . setAttribute ( 'height' , innerSize . height . toString ( ) ) ; const svgString = new XMLSerializer ( ) . serializeToString ( svg ) ; const { canvas , context } = createCanvas ( outerSize . width , outerSize . height , options . backgroundColor ) ; const image = await loadImage ( 'data:image/svg+xml,' + encodeURIComponent ( svgString ) ) ; context . drawImage ( image , offset . x , offset . y , innerSize . width , innerSize . height ) ; return canvas . toDataURL ( mimeType , options . quality ) ; function createCanvas ( canvasWidth , canvasHeight , backgroundColor ? ) { const cnv = document . createElement ( 'canvas' ) ; cnv . width = canvasWidth ; cnv . height = canvasHeight ; const cnt = cnv . getContext ( '2d' ) ; if ( backgroundColor ) { cnt . fillStyle = backgroundColor ; cnt . fillRect ( 0 , 0 , canvasWidth , canvasHeight ) ; } return { canvas : cnv , context : cnt } ; } } export function loadImage ( source ) { return new Promise ( ( resolve , reject ) => { const image = new Image ( ) ; image . onload = function ( ) { resolve ( image ) ; } ; image . onerror = function ( ev ) { reject ( ev ) ; } ; image . src = source ; } ) ; } function computeAutofit ( itemSize , containerSize ) { const fit = fitRectKeepingAspectRatio ( itemSize . width , itemSize . height , containerSize . width , containerSize . height ) ; const innerSize = { width : Math . floor ( fit . width ) , height : Math . floor ( fit . height ) , } ; const outerSize = { width : typeof containerSize . width === 'number' ? containerSize . width : innerSize . width , height : typeof containerSize . height === 'number' ? containerSize . height : innerSize . height , } ; const offset = { x : Math . round ( ( outerSize . width - innerSize . width ) / 2 ) , y : Math . round ( ( outerSize . height - innerSize . height ) / 2 ) , } ; return { innerSize , outerSize , offset } ; } function fallbackContainerSize ( itemSize ) { const maxResolutionScale = Math . min ( MAX_CANVAS_LENGTH / itemSize . width , MAX_CANVAS_LENGTH / itemSize . height ) ; const resolutionScale = Math . min ( 2.0 , maxResolutionScale ) ; const width = Math . floor ( itemSize . width * resolutionScale ) ; const height = Math . floor ( itemSize . height * resolutionScale ) ; return { width , height } ; } export function fitRectKeepingAspectRatio ( sourceWidth , sourceHeight , targetWidth , targetHeight ) { if ( ! ( typeof targetWidth === 'number' || typeof targetHeight === 'number' ) ) { return { width : sourceWidth , height : sourceHeight } ; } const sourceAspectRatio = sourceWidth / sourceHeight ; targetWidth = typeof targetWidth === 'number' ? targetWidth : targetHeight * sourceAspectRatio ; targetHeight = typeof targetHeight === 'number' ? targetHeight : targetWidth / sourceAspectRatio ; if ( targetHeight * sourceAspectRatio <= targetWidth ) { return { width : targetHeight * sourceAspectRatio , height : targetHeight } ; } else { return { width : targetWidth , height : targetWidth / sourceAspectRatio } ; } } param { string } dataURL return { Blob } export function dataURLToBlob ( dataURL ) { const BASE64_MARKER = ';base64,' ; if ( dataURL . indexOf ( BASE64_MARKER ) === - 1 ) { const parts = dataURL . split ( ',' ) ; const contentType = parts [ 0 ] . split ( ':' ) [ 1 ] ; const raw = decodeURIComponent ( parts [ 1 ] ) ; return new Blob ( [ raw ] , { type : contentType } ) ; } else { const parts = dataURL . split ( BASE64_MARKER ) ; const contentType = parts [ 0 ] . split ( ':' ) [ 1 ] ; const raw = window . atob ( parts [ 1 ] ) ; const rawLength = raw . length ; const uInt8Array = new Uint8Array ( rawLength ) ; for ( let i = 0 ; i < rawLength ; ++ i ) { uInt8Array [ i ] = raw . charCodeAt ( i ) ; } return new Blob ( [ uInt8Array ] , { type : contentType } ) ; } }