highcharts
Version:
JavaScript charting framework
505 lines (504 loc) • 20.3 kB
JavaScript
/* *
*
* Client side exporting module
*
* (c) 2015-2026 Highsoft AS
* Author: Torstein Hønsi / Øystein Moseng
*
* Integration of this software requires a license.
* - For commercial use, see www.highcharts.com/license
* - For non-commercial, see www.highcharts.com/license-eula
*
*
* */
;
import AST from '../../Core/Renderer/HTML/AST.js';
import Chart from '../../Core/Chart/Chart.js';
import D from '../../Core/Defaults.js';
const { getOptions, setOptions } = D;
import { downloadURL, getScript } from '../../Shared/DownloadURL.js';
import G from '../../Core/Globals.js';
const { composed, doc, win } = G;
import OfflineExportingDefaults from './OfflineExportingDefaults.js';
import { error } from '../../Core/Utilities.js';
import { addEvent, extend, pushUnique } from '../../Shared/Utilities.js';
/* *
*
* Composition
*
* */
var OfflineExporting;
(function (OfflineExporting) {
/* *
*
* Constants
*
* */
const invalidLibURLErrorPrefix = 'Invalid exporting.libURL:';
/* *
*
* Functions
*
* */
/**
* Check if the PDF export dependencies are already loaded on window.
*
* @private
*/
function hasPdfDependencies() {
return !!(win &&
win.jspdf?.jsPDF &&
win.svg2pdf);
}
/**
* Validate and normalize an opt-in libURL.
*
* @private
*/
function normalizeOptInLibURL(optInLibURL) {
if (!optInLibURL) {
return void 0;
}
const normalizedLibURL = optInLibURL.slice(-1) !== '/' ?
optInLibURL + '/' :
optInLibURL;
let isValidLibURL;
if (win.URL?.canParse) {
isValidLibURL = win.URL.canParse(normalizedLibURL, doc.baseURI);
}
else {
try {
// The baseURI allows both absolute and relative paths.
const parsedURL = new win.URL(normalizedLibURL, doc.baseURI);
isValidLibURL = !!parsedURL.href;
}
catch {
isValidLibURL = false;
}
}
if (!isValidLibURL) {
throw new Error(`${invalidLibURLErrorPrefix} "${optInLibURL}". ` +
'Provide a valid URL or path to the jsPDF and svg2pdf ' +
'scripts.');
}
return normalizedLibURL;
}
/**
* Composition function.
*
* @internal
* @function compose
*
* @param {ExportingClass} ExportingClass
* Exporting class.
*
* @requires modules/exporting
* @requires modules/offline-exporting
*/
function compose(ExportingClass) {
// Add the downloadSVG event to the Exporting class for local PDF export
addEvent(ExportingClass, 'downloadSVG', async function (e) {
const { svg, exportingOptions, exporting, preventDefault } = e;
const chart = exporting?.chart;
// Check if PDF export is requested
if (exportingOptions?.type === 'application/pdf') {
// Prevent the default export behavior
preventDefault?.();
// Run the PDF local export
try {
// Get the final image options
const { type, filename, scale } = G.Exporting.prepareImageOptions(exportingOptions);
const optInLibURL = exportingOptions.libURL ||
chart?.options.exporting?.libURL;
const normalizedLibURL = normalizeOptInLibURL(optInLibURL);
// Local PDF download
if (type === 'application/pdf') {
if (!hasPdfDependencies()) {
if (!normalizedLibURL) {
throw new Error('PDF export requires jsPDF and svg2pdf.');
}
// Must load pdf libraries first if not found.
// Don't destroy the object URL yet since we are
// doing things asynchronously
if (!win.jspdf?.jsPDF) {
await getScript(`${normalizedLibURL}jspdf.js`);
}
if (!win.svg2pdf) {
await getScript(`${normalizedLibURL}svg2pdf.js`);
}
}
// Call the PDF download if SVG element found
await downloadPDF(svg, scale, filename, exportingOptions?.pdfFont);
}
}
catch (caughtError) {
const exportError = caughtError;
if (exportingOptions?.fallbackToExportServer !==
false &&
exportError.message.indexOf(invalidLibURLErrorPrefix) === 0) {
error(`${exportError.message} Falling back to ` +
'export server.', false, chart);
}
// Try to fallback to the server
await exporting?.fallbackToServer(exportingOptions, exportError);
}
}
});
// Check the composition registry for the OfflineExporting
if (!pushUnique(composed, 'OfflineExporting')) {
return;
}
addEvent(Chart, 'load', function () {
// The load event also runs for server-export chart copies.
// Skip warnings in that case.
if (this.renderer.forExport || hasPdfDependencies()) {
return;
}
if (!this.options.exporting?.libURL) {
error('Warning: exporting.libURL not defined, PDF client side ' +
'export will not work', false, this);
}
});
// Adding wrappers for the deprecated functions
extend(Chart.prototype, {
exportChartLocal: async function (exportingOptions, chartOptions) {
await this.exporting?.exportChart(exportingOptions, chartOptions);
return;
}
});
// Update with defaults of the offline exporting module
setOptions(OfflineExportingDefaults);
// Additionally, extend menuItems with the offline exporting variants
const menuItems = getOptions().exporting?.buttons?.contextButton?.menuItems;
menuItems?.push('downloadPDF');
}
OfflineExporting.compose = compose;
/**
* Deprecated. Use
* [Exporting.downloadSVG](https://api.highcharts.com/class-reference/Highcharts.Exporting#downloadSVG)
* instead.
*
* Get data URL to an image of an SVG and call download on it options
* object:
* - **filename:** Name of resulting downloaded file without extension.
* Default is `chart`.
*
* - **type:** File type of resulting download. Default is `image/png`.
*
* - **scale:** Scaling factor of downloaded image compared to source.
* Default is `1`.
* - **libURL:** URL pointing to location of dependency scripts to download
* on demand.
*
* @function Highcharts.downloadSVGLocal
* @deprecated 11.4.4
*
* @param {string} svg
* The generated SVG
*
* @param {Highcharts.ExportingOptions} options
* The exporting options
*
*/
async function downloadSVGLocal(svg, options) {
await G.Exporting.prototype.downloadSVG.call(void 0, svg, options);
}
OfflineExporting.downloadSVGLocal = downloadSVGLocal;
/**
* Converts an SVG string into a PDF file and triggers its download. This
* function processes the SVG, applies necessary font adjustments, converts
* it to a PDF, and initiates the file download.
*
* @internal
* @async
* @function downloadPDF
*
* @param {string} svg
* A string representation of the SVG markup to be converted into a PDF.
* @param {number} scale
* The scaling factor for the PDF output.
* @param {string} filename
* The name of the downloaded PDF file.
* @param {Highcharts.PdfFontOptions} [pdfFont]
* An optional object specifying URLs for different font variants (normal,
* bold, italic, bolditalic).
*
* @return {Promise<void>}
* A promise that resolves when the PDF has been successfully generated and
* downloaded.
*
* @requires modules/exporting
* @requires modules/offline-exporting
*/
async function downloadPDF(svg, scale, filename, pdfFont) {
const svgNode = preparePDF(svg, pdfFont);
if (svgNode) {
// Loads all required fonts
await loadPdfFonts(svgNode, pdfFont);
// Transform SVG to PDF
const pdfData = await svgToPdf(svgNode, 0, scale);
// Download the PDF
downloadURL(pdfData, filename);
}
}
/**
* Loads and registers custom fonts for PDF export if non-ASCII characters
* are detected in the given SVG element. This function ensures that text
* content with special characters is properly rendered in the exported PDF.
*
* It fetches font files (if provided in `pdfFont`), converts them to
* base64, and registers them with jsPDF.
*
* @internal
* @function loadPdfFonts
*
* @param {SVGElement} svgElement
* The generated SVG element containing the text content to be exported.
* @param {Highcharts.PdfFontOptions} [pdfFont]
* An optional object specifying URLs for different font variants (normal,
* bold, italic, bolditalic). If non-ASCII characters are not detected,
* fonts are not loaded.
*
* @requires modules/exporting
* @requires modules/offline-exporting
*/
async function loadPdfFonts(svgElement, pdfFont) {
const hasNonASCII = (s) => (
// 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
const addFont = (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
const variants = ['normal', 'italic', 'bold', 'bolditalic'];
// Shift the first element off the variants and add as a font.
// Then asynchronously trigger the next variant until variants are empty
let normalBase64;
for (const variant of variants) {
const url = pdfFont?.[variant];
if (url) {
try {
const response = await win.fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch font: ${url}`);
}
const blob = await response.blob(), reader = new FileReader();
const base64 = await new Promise((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result.split(',')[1]);
}
else {
reject(new Error('Failed to read font as base64'));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
addFont(variant, base64);
if (variant === 'normal') {
normalBase64 = base64;
}
}
catch {
// If fetch or reading fails, fallback to next variant
}
}
else {
// For other variants, fall back to normal text weight/style
if (normalBase64) {
addFont(variant, normalBase64);
}
}
}
}
/**
* Prepares an SVG for PDF export by ensuring proper text styling and
* removing unnecessary elements. This function extracts an SVG element from
* a given SVG string, applies font styles inherited from parent elements,
* and removes text outlines and title elements to improve PDF rendering.
*
* @internal
* @function preparePDF
*
* @param {string} svg
* A string representation of the SVG markup.
* @param {Highcharts.PdfFontOptions} [pdfFont]
* An optional object specifying URLs for different font variants (normal,
* bold, italic, bolditalic). If provided, the text elements are assigned a
* custom PDF font.
*
* @return {SVGSVGElement | null}
* Returns the parsed SVG element from the container or `null` if the SVG is
* not found.
*
* @requires modules/exporting
* @requires modules/offline-exporting
*/
function preparePDF(svg, pdfFont) {
const dummySVGContainer = doc.createElement('div');
AST.setElementHTML(dummySVGContainer, svg);
const 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) {
let curParent = el;
while (curParent && curParent !== dummySVGContainer) {
if (curParent.style[propName]) {
let value = curParent.style[propName];
if (propName === 'fontSize' && /em$/.test(value)) {
value = Math.round(parseFloat(value) * 16) + 'px';
}
el.style[propName] = value;
break;
}
curParent = curParent.parentNode;
}
};
let 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
['fontFamily', 'fontSize']
.forEach((property) => {
setStylePropertyFromParents(el, property);
});
el.style.fontFamily = 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) {
titleElement.remove();
});
// Remove all .highcharts-text-outline elements, #17170
outlineElements =
el.getElementsByClassName('highcharts-text-outline');
while (outlineElements.length > 0) {
outlineElements[0].remove();
}
});
// Work around jsPDF's missing support for color(srgb r g b) format
// (#25001)
const srgbColorRegex = /color\(\s*srgb\s+([\d.]+)[, ]+([\d.]+)[, ]+([\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)/; // eslint-disable-line max-len
const convertColorSRGBToRGB = (srgbColor) => {
if (srgbColor?.startsWith('color(srgb')) {
const rgba = srgbColor.match(srgbColorRegex);
if (rgba) {
const toChannel = (value) => Math.round(Math.max(0, Math.min(1, parseFloat(value))) *
255), toAlpha = (value) => Math.max(0, Math.min(1, parseFloat(value))), r = toChannel(rgba[1]), g = toChannel(rgba[2]), b = toChannel(rgba[3]), alpha = rgba[4];
if (alpha !== void 0) {
return `rgba(${r}, ${g}, ${b}, ${toAlpha(alpha)})`;
}
return `rgb(${r}, ${g}, ${b})`;
}
}
};
Array.from(dummySVGContainer.querySelectorAll('*')).forEach((el) => {
['color', 'fill', 'stop-color', 'stroke'].forEach((prop) => {
// Handle attributes
const attrRGB = convertColorSRGBToRGB(el.getAttribute(prop));
if (attrRGB) {
el.setAttribute(prop, attrRGB);
}
// Handle style properties
const styleRGB = convertColorSRGBToRGB(el.style?.[prop]);
if (styleRGB) {
el.style[prop] = styleRGB;
}
});
});
return dummySVGContainer.querySelector('svg');
}
/**
* Transform from PDF to SVG.
*
* @async
* @internal
* @function svgToPdf
*
* @param {Highcharts.SVGElement} svgElement
* The SVG element to convert.
* @param {number} margin
* The margin to apply.
* @param {number} scale
* The scale of the SVG.
*
* @requires modules/exporting
* @requires modules/offline-exporting
*/
async function svgToPdf(svgElement, margin, scale) {
const width = (Number(svgElement.getAttribute('width')) + 2 * margin) *
scale, height = (Number(svgElement.getAttribute('height')) + 2 * margin) *
scale, pdfDoc = new win.jspdf.jsPDF(// eslint-disable-line new-cap
// Setting orientation to portrait if height exceeds width
height > width ? 'p' : 'l', 'pt', [width, height]);
// Workaround for #7090, hidden elements were drawn anyway. It comes
// down to https://github.com/yWorks/svg2pdf.js/issues/28. Check this
// later.
[].forEach.call(svgElement.querySelectorAll('*[visibility="hidden"]'), function (node) {
node.parentNode.removeChild(node);
});
// Workaround for #13948, multiple stops in linear gradient set to 0
// causing error in Acrobat
const gradients = svgElement.querySelectorAll('linearGradient');
for (let index = 0; index < gradients.length; index++) {
const gradient = gradients[index];
const stops = gradient.querySelectorAll('stop');
let i = 0;
while (i < stops.length &&
stops[i].getAttribute('offset') === '0' &&
stops[i + 1].getAttribute('offset') === '0') {
stops[i].remove();
i++;
}
}
// Workaround for #15135, zero width spaces, which Highcharts uses
// to break lines, are not correctly rendered in PDF. Replace it
// with a regular space and offset by some pixels to compensate.
[].forEach.call(svgElement.querySelectorAll('tspan'), (tspan) => {
if (tspan.textContent === '\u200B') {
tspan.textContent = ' ';
tspan.setAttribute('dx', -5);
}
});
// Transform from PDF to SVG
await pdfDoc.svg(svgElement, {
x: 0,
y: 0,
width,
height,
removeInvalid: true
});
// Return the output
return pdfDoc.output('datauristring');
}
})(OfflineExporting || (OfflineExporting = {}));
/* *
*
* Default Export
*
* */
export default OfflineExporting;