UNPKG

highcharts

Version:
505 lines (504 loc) 20.3 kB
/* * * * 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 * * * */ 'use strict'; 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;