From 7c3c3f04f7bdc82f85cf5ef8d243cf22412d2391 Mon Sep 17 00:00:00 2001 From: HendrikThePendric Date: Thu, 14 Nov 2024 11:32:37 +0100 Subject: [PATCH 1/4] chore: add offline exporting module to highcharts --- src/visualizations/config/generators/highcharts/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/visualizations/config/generators/highcharts/index.js b/src/visualizations/config/generators/highcharts/index.js index f087f06bd..2adca1763 100644 --- a/src/visualizations/config/generators/highcharts/index.js +++ b/src/visualizations/config/generators/highcharts/index.js @@ -3,6 +3,7 @@ import HM from 'highcharts/highcharts-more' import HB from 'highcharts/modules/boost' import HE from 'highcharts/modules/exporting' import HNDTD from 'highcharts/modules/no-data-to-display' +import HOE from 'highcharts/modules/offline-exporting' import HPF from 'highcharts/modules/pattern-fill' import HSG from 'highcharts/modules/solid-gauge' @@ -11,6 +12,7 @@ HM(H) HSG(H) HNDTD(H) HE(H) +HOE(H) HPF(H) HB(H) From a84420961702697ac4f7026f754ad576e713659a Mon Sep 17 00:00:00 2001 From: HendrikThePendric Date: Thu, 14 Nov 2024 11:33:54 +0100 Subject: [PATCH 2/4] chore: add pdf export bugfixes supplied by highcharts --- .../config/generators/highcharts/index.js | 2 + .../highcharts/pdfExportBugFixPlugin/index.js | 7 + .../pdfExportBugFixPlugin/nonASCIIFont.js | 9 + .../pdfExportBugFixPlugin/textShadow.js | 308 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js diff --git a/src/visualizations/config/generators/highcharts/index.js b/src/visualizations/config/generators/highcharts/index.js index 2adca1763..3620e81f5 100644 --- a/src/visualizations/config/generators/highcharts/index.js +++ b/src/visualizations/config/generators/highcharts/index.js @@ -6,6 +6,7 @@ import HNDTD from 'highcharts/modules/no-data-to-display' import HOE from 'highcharts/modules/offline-exporting' import HPF from 'highcharts/modules/pattern-fill' import HSG from 'highcharts/modules/solid-gauge' +import PEBFP from './pdfExportBugFixPlugin/index.js' // apply HM(H) @@ -15,6 +16,7 @@ HE(H) HOE(H) HPF(H) HB(H) +PEBFP(H) /* Whitelist some additional SVG attributes here. Without this, * the PDF export for the SingleValue visualization breaks. */ diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js new file mode 100644 index 000000000..7b4899cde --- /dev/null +++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js @@ -0,0 +1,7 @@ +import nonASCIIFontBugfix from './nonASCIIFont.js' +import textShadowBugFix from './textShadow.js' + +export default function (H) { + textShadowBugFix(H) + nonASCIIFontBugfix(H) +} diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js new file mode 100644 index 000000000..d2c8d9835 --- /dev/null +++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js @@ -0,0 +1,9 @@ +/* This is a workaround for https://github.com/highcharts/highcharts/issues/22008 + * We add some transparent text in a non-ASCII script to the chart to prevent + * the chart from being exported in a serif font */ + +export default function (H) { + H.addEvent(H.Chart, 'load', function () { + this.renderer.text('ыки', 20, 20).attr({ opacity: 0 }).add() + }) +} diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js new file mode 100644 index 000000000..21a96e1a5 --- /dev/null +++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js @@ -0,0 +1,308 @@ +/* This plugin was provided by HighCharts support and resolves an issue with label + * text that has a white outline, such as the one we use for stacked bar charts. + * For example: "ANC: 1-4 visits by districts this year (stacked)" + * This issue has actually been resolved in HighCharts v11, so once we have upgraded + * to that version, this plugin can be removed. */ + +export default function (H) { + const { AST, defaultOptions, downloadURL } = H, + { ajax } = H.HttpUtilities, + doc = document, + win = window, + OfflineExporting = + H._modules['Extensions/OfflineExporting/OfflineExporting.js'], + { getScript, svgToPdf, imageToDataUrl, svgToDataUrl } = OfflineExporting + + H.wrap( + OfflineExporting, + 'downloadSVGLocal', + function (proceed, svg, options, failCallback, successCallback) { + var dummySVGContainer = doc.createElement('div'), + imageType = options.type || 'image/png', + filename = + (options.filename || 'chart') + + '.' + + (imageType === 'image/svg+xml' + ? 'svg' + : imageType.split('/')[1]), + scale = options.scale || 1 + var svgurl, + blob, + finallyHandler, + libURL = options.libURL || defaultOptions.exporting.libURL, + objectURLRevoke = true, + pdfFont = options.pdfFont + // Allow libURL to end with or without fordward slash + libURL = libURL.slice(-1) !== '/' ? libURL + '/' : libURL + /* + * Detect if we need to load TTF fonts for the PDF, then load them and + * proceed. + * + * @private + */ + var loadPdfFonts = function (svgElement, callback) { + var hasNonASCII = function (s) { + return ( + // eslint-disable-next-line no-control-regex + /[^\u0000-\u007F\u200B]+/.test(s) + ) + } + // Register an event in order to add the font once jsPDF is + // initialized + var addFont = function (variant, base64) { + win.jspdf.jsPDF.API.events.push([ + 'initialized', + function () { + this.addFileToVFS(variant, base64) + this.addFont(variant, 'HighchartsFont', variant) + if (!this.getFontList().HighchartsFont) { + this.setFont('HighchartsFont') + } + }, + ]) + } + // If there are no non-ASCII characters in the SVG, do not use + // bother downloading the font files + if (pdfFont && !hasNonASCII(svgElement.textContent || '')) { + pdfFont = void 0 + } + // Add new font if the URL is declared, #6417. + var variants = ['normal', 'italic', 'bold', 'bolditalic'] + // Shift the first element off the variants and add as a font. + // Then asynchronously trigger the next variant until calling the + // callback when the variants are empty. + var normalBase64 + var shiftAndLoadVariant = function () { + var variant = variants.shift() + // All variants shifted and possibly loaded, proceed + if (!variant) { + return callback() + } + var url = pdfFont && pdfFont[variant] + if (url) { + ajax({ + url: url, + responseType: 'blob', + success: function (data, xhr) { + var reader = new FileReader() + reader.onloadend = function () { + if (typeof this.result === 'string') { + var base64 = this.result.split(',')[1] + addFont(variant, base64) + if (variant === 'normal') { + normalBase64 = base64 + } + } + shiftAndLoadVariant() + } + reader.readAsDataURL(xhr.response) + }, + error: shiftAndLoadVariant, + }) + } else { + // For other variants, fall back to normal text weight/style + if (normalBase64) { + addFont(variant, normalBase64) + } + shiftAndLoadVariant() + } + } + shiftAndLoadVariant() + } + /* + * @private + */ + var downloadPDF = function () { + AST.setElementHTML(dummySVGContainer, svg) + var textElements = + dummySVGContainer.getElementsByTagName('text'), + // Copy style property to element from parents if it's not + // there. Searches up hierarchy until it finds prop, or hits the + // chart container. + setStylePropertyFromParents = function (el, propName) { + var curParent = el + while (curParent && curParent !== dummySVGContainer) { + if (curParent.style[propName]) { + el.style[propName] = curParent.style[propName] + break + } + curParent = curParent.parentNode + } + } + var titleElements, + outlineElements + // Workaround for the text styling. Making sure it does pick up + // settings for parent elements. + ;[].forEach.call(textElements, function (el) { + // Workaround for the text styling. making sure it does pick up + // the root element + ;['font-family', 'font-size'].forEach(function (property) { + setStylePropertyFromParents(el, property) + }) + el.style.fontFamily = + pdfFont && pdfFont.normal + ? // Custom PDF font + 'HighchartsFont' + : // Generic font (serif, sans-serif etc) + String( + el.style.fontFamily && + el.style.fontFamily.split(' ').splice(-1) + ) + // Workaround for plotband with width, removing title from text + // nodes + titleElements = el.getElementsByTagName('title') + ;[].forEach.call(titleElements, function (titleElement) { + el.removeChild(titleElement) + }) + + // Remove all .highcharts-text-outline elements, #17170 + outlineElements = el.getElementsByClassName( + 'highcharts-text-outline' + ) + while (outlineElements.length > 0) { + const outline = outlineElements[0] + if (outline.parentNode) { + outline.parentNode.removeChild(outline) + } + } + }) + var svgNode = dummySVGContainer.querySelector('svg') + if (svgNode) { + loadPdfFonts(svgNode, function () { + svgToPdf(svgNode, 0, function (pdfData) { + try { + downloadURL(pdfData, filename) + if (successCallback) { + successCallback() + } + } catch (e) { + failCallback(e) + } + }) + }) + } + } + // Initiate download depending on file type + if (imageType === 'image/svg+xml') { + // SVG download. In this case, we want to use Microsoft specific + // Blob if available + try { + if (typeof win.navigator.msSaveOrOpenBlob !== 'undefined') { + // eslint-disable-next-line no-undef + blob = new MSBlobBuilder() + blob.append(svg) + svgurl = blob.getBlob('image/svg+xml') + } else { + svgurl = svgToDataUrl(svg) + } + downloadURL(svgurl, filename) + if (successCallback) { + successCallback() + } + } catch (e) { + failCallback(e) + } + } else if (imageType === 'application/pdf') { + if (win.jspdf && win.jspdf.jsPDF) { + downloadPDF() + } else { + // Must load pdf libraries first. // Don't destroy the object + // URL yet since we are doing things asynchronously. A cleaner + // solution would be nice, but this will do for now. + objectURLRevoke = true + getScript(libURL + 'jspdf.js', function () { + getScript(libURL + 'svg2pdf.js', downloadPDF) + }) + } + } else { + // PNG/JPEG download - create bitmap from SVG + svgurl = svgToDataUrl(svg) + finallyHandler = function () { + try { + OfflineExporting.domurl.revokeObjectURL(svgurl) + } catch (e) { + // Ignore + } + } + // First, try to get PNG by rendering on canvas + imageToDataUrl( + svgurl, + imageType, + {}, + scale, + function (imageURL) { + // Success + try { + downloadURL(imageURL, filename) + if (successCallback) { + successCallback() + } + } catch (e) { + failCallback(e) + } + }, + function () { + // Failed due to tainted canvas + // Create new and untainted canvas + var canvas = doc.createElement('canvas'), + ctx = canvas.getContext('2d'), + imageWidth = + svg.match( + // eslint-disable-next-line no-useless-escape + /^]*width\s*=\s*\"?(\d+)\"?[^>]*>/ + )[1] * scale, + imageHeight = + svg.match( + // eslint-disable-next-line no-useless-escape + /^]*height\s*=\s*\"?(\d+)\"?[^>]*>/ + )[1] * scale, + downloadWithCanVG = function () { + var v = win.canvg.Canvg.fromString(ctx, svg) + v.start() + try { + downloadURL( + win.navigator.msSaveOrOpenBlob + ? canvas.msToBlob() + : canvas.toDataURL(imageType), + filename + ) + if (successCallback) { + successCallback() + } + } catch (e) { + failCallback(e) + } finally { + finallyHandler() + } + } + canvas.width = imageWidth + canvas.height = imageHeight + if (win.canvg) { + // Use preloaded canvg + downloadWithCanVG() + } else { + // Must load canVG first. // Don't destroy the object + // URL yet since we are doing things asynchronously. A + // cleaner solution would be nice, but this will do for + // now. + objectURLRevoke = true + getScript(libURL + 'canvg.js', function () { + downloadWithCanVG() + }) + } + }, + // No canvas support + failCallback, + // Failed to load image + failCallback, + // Finally + function () { + if (objectURLRevoke) { + finallyHandler() + } + } + ) + } + } + ) +} From f7390d35c27e5ea3997d32ccabd920be836cbe3c Mon Sep 17 00:00:00 2001 From: HendrikThePendric Date: Thu, 14 Nov 2024 11:35:23 +0100 Subject: [PATCH 3/4] fix: render single value main text in normal font weight when exporting to pdf --- .../events/loadCustomSVG/singleValue/index.js | 2 +- .../events/loadCustomSVG/singleValue/styles.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js index 268d2c547..84cc83e7d 100644 --- a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js +++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js @@ -7,7 +7,7 @@ import { DynamicStyles } from './styles.js' export default function loadSingleValueSVG() { const { formattedValue, icon, subText, fontColor } = this.userOptions.customSVGOptions - const dynamicStyles = new DynamicStyles() + const dynamicStyles = new DynamicStyles(this.userOptions?.isPdfExport) const valueElement = this.renderer .text(formattedValue) .attr('data-test', 'visualization-primary-value') diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js index e7ec189fd..f1b944ee2 100644 --- a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js +++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js @@ -28,14 +28,15 @@ const spacings = [ export const MIN_SIDE_WHITESPACE = 4 export class DynamicStyles { - constructor() { + constructor(isPdfExport) { this.currentIndex = 0 + this.isPdfExport = isPdfExport } getStyle() { return { value: { ...valueStyles[this.currentIndex], - 'font-weight': '300', + 'font-weight': this.isPdfExport ? 'normal' : '300', }, subText: subTextStyles[this.currentIndex], spacing: spacings[this.currentIndex], From 6e8c8bed476cacc14c52fe8ae6c46934cdb09d3d Mon Sep 17 00:00:00 2001 From: HendrikThePendric Date: Thu, 14 Nov 2024 11:35:56 +0100 Subject: [PATCH 4/4] chore: add offline exporting functionality to single value demo story --- .storybook/preview-head.html | 6 ++++ src/__demo__/SingleValue.stories.js | 45 ++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .storybook/preview-head.html diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..965f8201c --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,6 @@ + + + diff --git a/src/__demo__/SingleValue.stories.js b/src/__demo__/SingleValue.stories.js index 2b382123f..f0925b88c 100644 --- a/src/__demo__/SingleValue.stories.js +++ b/src/__demo__/SingleValue.stories.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react' +import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { createVisualization } from '../index.js' const constainerStyleBase = { width: 800, @@ -636,6 +636,7 @@ export const Default = () => { const [dashboard, setDashboard] = useState(false) const [showIcon, setShowIcon] = useState(true) const [indicatorType, setIndicatorType] = useState('plain') + const [exportAsPdf, setExportAsPdf] = useState(true) const [width, setWidth] = useState(constainerStyleBase.width) const [height, setHeight] = useState(constainerStyleBase.height) const containerStyle = useMemo( @@ -646,6 +647,39 @@ export const Default = () => { }), [width, height] ) + const downloadOffline = useCallback(() => { + if (newChartRef.current) { + const currentBackgroundColor = + newChartRef.current.userOptions.chart.backgroundColor + + newChartRef.current.update({ + exporting: { + chartOptions: { + isPdfExport: exportAsPdf, + }, + }, + }) + newChartRef.current.exportChartLocal( + { + sourceHeight: 768, + sourceWidth: 1024, + scale: 1, + fallbackToExportServer: false, + filename: 'testOfflineDownload', + showExportInProgress: true, + type: exportAsPdf ? 'application/pdf' : 'image/png', + }, + { + chart: { + backgroundColor: + currentBackgroundColor === 'transparent' + ? '#ffffff' + : currentBackgroundColor, + }, + } + ) + } + }, [exportAsPdf]) useEffect(() => { if (newContainerRef.current) { requestAnimationFrame(() => { @@ -748,6 +782,15 @@ export const Default = () => { })} + +