From e42c0967ff17258a3bbc5b33ea52af5828246458 Mon Sep 17 00:00:00 2001 From: Juned Chhipa Date: Thu, 29 Aug 2024 22:48:49 +0530 Subject: [PATCH] fix #4016; exporting with image fill --- src/charts/common/bar/Helpers.js | 2 +- src/modules/Exports.js | 132 ++++++++++++++++-------- src/modules/Fill.js | 39 ++++--- src/modules/helpers/InitCtxVariables.js | 2 + 4 files changed, 116 insertions(+), 59 deletions(-) diff --git a/src/charts/common/bar/Helpers.js b/src/charts/common/bar/Helpers.js index 7d55633b1..6b2f113f2 100644 --- a/src/charts/common/bar/Helpers.js +++ b/src/charts/common/bar/Helpers.js @@ -185,7 +185,7 @@ export default class Helpers { getPathFillColor(series, i, j, realIndex) { const w = this.w - let fill = new Fill(this.barCtx.ctx) + let fill = this.barCtx.ctx.fill let fillColor = null let seriesNumber = this.barCtx.barOptions.distributed ? j : i diff --git a/src/modules/Exports.js b/src/modules/Exports.js index 26137afa9..80d165a50 100644 --- a/src/modules/Exports.js +++ b/src/modules/Exports.js @@ -20,25 +20,67 @@ class Exports { } getSvgString() { - const w = this.w - const width = w.config.chart.toolbar.export.width - let scale = - w.config.chart.toolbar.export.scale || width / w.globals.svgWidth + return new Promise((resolve) => { + const w = this.w + const width = w.config.chart.toolbar.export.width + let scale = + w.config.chart.toolbar.export.scale || width / w.globals.svgWidth + + if (!scale) { + scale = 1 // if no scale is specified, don't scale... + } + let svgString = this.w.globals.dom.Paper.svg() - if (!scale) { - scale = 1 // if no scale is specified, don't scale... - } - let svgString = this.w.globals.dom.Paper.svg() - // in case the scale is different than 1, the svg needs to be rescaled - if (scale !== 1) { // clone the svg node so it remains intact in the UI const svgNode = this.w.globals.dom.Paper.node.cloneNode(true) - // scale the image - this.scaleSvgNode(svgNode, scale) - // get the string representation of the svgNode - svgString = new XMLSerializer().serializeToString(svgNode) - } - return svgString.replace(/ /g, ' ') + + // in case the scale is different than 1, the svg needs to be rescaled + + if (scale !== 1) { + // scale the image + this.scaleSvgNode(svgNode, scale) + } + // Convert image URLs to base64 + this.convertImagesToBase64(svgNode).then(() => { + svgString = new XMLSerializer().serializeToString(svgNode) + resolve(svgString.replace(/ /g, ' ')) + }) + }) + } + + convertImagesToBase64(svgNode) { + const images = svgNode.getElementsByTagName('image') + const promises = Array.from(images).map((img) => { + const href = img.getAttributeNS('http://www.w3.org/1999/xlink', 'href') + if (href && !href.startsWith('data:')) { + return this.getBase64FromUrl(href) + .then((base64) => { + img.setAttributeNS('http://www.w3.org/1999/xlink', 'href', base64) + }) + .catch((error) => { + console.error('Error converting image to base64:', error) + }) + } + return Promise.resolve() + }) + return Promise.all(promises) + } + + getBase64FromUrl(url) { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'Anonymous' + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0) + resolve(canvas.toDataURL()) + } + img.onerror = reject + img.src = url + }) } cleanup() { @@ -70,11 +112,15 @@ class Exports { } svgUrl() { - this.cleanup() - - const svgData = this.getSvgString() - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) - return URL.createObjectURL(svgBlob) + return new Promise((resolve) => { + this.cleanup() + this.getSvgString().then((svgData) => { + const svgBlob = new Blob([svgData], { + type: 'image/svg+xml;charset=utf-8', + }) + resolve(URL.createObjectURL(svgBlob)) + }) + }) } dataURI(options) { @@ -100,35 +146,37 @@ class Exports { ctx.fillStyle = canvasBg ctx.fillRect(0, 0, canvas.width * scale, canvas.height * scale) - const svgData = this.getSvgString() + this.getSvgString().then((svgData) => { + const svgUrl = 'data:image/svg+xml,' + encodeURIComponent(svgData) + let img = new Image() + img.crossOrigin = 'anonymous' - const svgUrl = 'data:image/svg+xml,' + encodeURIComponent(svgData) - let img = new Image() - img.crossOrigin = 'anonymous' + img.onload = () => { + ctx.drawImage(img, 0, 0) - img.onload = () => { - ctx.drawImage(img, 0, 0) - - if (canvas.msToBlob) { - // Microsoft Edge can't navigate to data urls, so we return the blob instead - let blob = canvas.msToBlob() - resolve({ blob }) - } else { - let imgURI = canvas.toDataURL('image/png') - resolve({ imgURI }) + if (canvas.msToBlob) { + // Microsoft Edge can't navigate to data urls, so we return the blob instead + let blob = canvas.msToBlob() + resolve({ blob }) + } else { + let imgURI = canvas.toDataURL('image/png') + resolve({ imgURI }) + } } - } - img.src = svgUrl + img.src = svgUrl + }) }) } exportToSVG() { - this.triggerDownload( - this.svgUrl(), - this.w.config.chart.toolbar.export.svg.filename, - '.svg' - ) + this.svgUrl().then((url) => { + this.triggerDownload( + url, + this.w.config.chart.toolbar.export.svg.filename, + '.svg' + ) + }) } exportToPng() { diff --git a/src/modules/Fill.js b/src/modules/Fill.js index 9bd058eec..05827d7cc 100644 --- a/src/modules/Fill.js +++ b/src/modules/Fill.js @@ -14,6 +14,7 @@ class Fill { this.opts = null this.seriesIndex = 0 + this.patternIDs = [] } clippedImgArea(params) { @@ -176,23 +177,29 @@ class Fill { let imgSrc = cnf.fill.image.src let patternID = opts.patternID ? opts.patternID : '' - this.clippedImgArea({ - opacity: fillOpacity, - image: Array.isArray(imgSrc) - ? opts.seriesNumber < imgSrc.length - ? imgSrc[opts.seriesNumber] - : imgSrc[0] - : imgSrc, - width: opts.width ? opts.width : undefined, - height: opts.height ? opts.height : undefined, - patternUnits: opts.patternUnits, - patternID: `pattern${w.globals.cuid}${ - opts.seriesNumber + 1 - }${patternID}`, - }) - pathFill = `url(#pattern${w.globals.cuid}${ + const patternKey = `pattern${w.globals.cuid}${ opts.seriesNumber + 1 - }${patternID})` + }${patternID}` + + if (this.patternIDs.indexOf(patternKey) === -1) { + console.log('patternKey', patternKey) + this.clippedImgArea({ + opacity: fillOpacity, + image: Array.isArray(imgSrc) + ? opts.seriesNumber < imgSrc.length + ? imgSrc[opts.seriesNumber] + : imgSrc[0] + : imgSrc, + width: opts.width ? opts.width : undefined, + height: opts.height ? opts.height : undefined, + patternUnits: opts.patternUnits, + patternID: patternKey, + }) + + this.patternIDs.push(patternKey) + } + + pathFill = `url(#${patternKey})` } else if (fillType === 'gradient') { pathFill = gradientFill } else if (fillType === 'pattern') { diff --git a/src/modules/helpers/InitCtxVariables.js b/src/modules/helpers/InitCtxVariables.js index 0c05b7e04..3e7f8175b 100644 --- a/src/modules/helpers/InitCtxVariables.js +++ b/src/modules/helpers/InitCtxVariables.js @@ -8,6 +8,7 @@ import Crosshairs from '../Crosshairs' import Grid from '../axes/Grid' import Graphics from '../Graphics' import Exports from '../Exports' +import Fill from '../Fill.js' import Options from '../settings/Options' import Responsive from '../Responsive' import Series from '../Series' @@ -90,6 +91,7 @@ export default class InitCtxVariables { this.ctx.crosshairs = new Crosshairs(this.ctx) this.ctx.events = new Events(this.ctx) this.ctx.exports = new Exports(this.ctx) + this.ctx.fill = new Fill(this.ctx) this.ctx.localization = new Localization(this.ctx) this.ctx.options = new Options() this.ctx.responsive = new Responsive(this.ctx)