UNPKG

highcharts-export-server

Version:

Convert Highcharts.JS charts into static image files.

469 lines (411 loc) 13.5 kB
/******************************************************************************* Highcharts Export Server Copyright (c) 2016-2024, Highsoft Licenced under the MIT licence. Additionally a valid Highcharts license is required for use. See LICENSE file in root for details. *******************************************************************************/ import { readFileSync } from 'fs'; import { join } from 'path'; import { fileURLToPath } from 'url'; import { defaultConfig } from '../lib/schemas/config.js'; import { log, logWithStack } from './logger.js'; const MAX_BACKOFF_ATTEMPTS = 6; export const __dirname = fileURLToPath(new URL('../.', import.meta.url)); /** * Clears and standardizes text by replacing multiple consecutive whitespace * characters with a single space and trimming any leading or trailing * whitespace. * * @param {string} text - The input text to be cleared. * @param {RegExp} [rule=/\s\s+/g] - The regular expression rule to match * multiple consecutive whitespace characters. * @param {string} [replacer=' '] - The string used to replace multiple * consecutive whitespace characters. * * @returns {string} - The cleared and standardized text. */ export const clearText = (text, rule = /\s\s+/g, replacer = ' ') => text.replaceAll(rule, replacer).trim(); /** * Implements an exponential backoff strategy for retrying a function until * a certain number of attempts are reached. * * @param {Function} fn - The function to be retried. * @param {number} [attempt=0] - The current attempt number. * @param {...any} args - Arguments to be passed to the function. * * @returns {Promise} - A promise that resolves to the result of the function * if successful. * * @throws {Error} - Throws an error if the maximum number of attempts * is reached. */ export const expBackoff = async (fn, attempt = 0, ...args) => { try { // Try to call the function return await fn(...args); } catch (error) { // Calculate delay in ms const delayInMs = 2 ** attempt * 1000; // If the attempt exceeds the maximum attempts of reapeat, throw an error if (++attempt >= MAX_BACKOFF_ATTEMPTS) { throw error; } // Wait given amount of time await new Promise((response) => setTimeout(response, delayInMs)); log( 3, `[pool] Waited ${delayInMs}ms until next call for the resource id: ${args[0]}.` ); // Try again return expBackoff(fn, attempt, ...args); } }; /** * Fixes the export type based on MIME types and file extensions. * * @param {string} type - The original export type. * @param {string} outfile - The file path or name. * * @returns {string} - The corrected export type. */ export const fixType = (type, outfile) => { // MIME types const mimeTypes = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'application/pdf': 'pdf', 'image/svg+xml': 'svg' }; // Formats const formats = ['png', 'jpeg', 'pdf', 'svg']; // Check if type and outfile's extensions are the same if (outfile) { const outType = outfile.split('.').pop(); if (outType === 'jpg') { type = 'jpeg'; } else if (formats.includes(outType) && type !== outType) { type = outType; } } // Return a correct type return mimeTypes[type] || formats.find((t) => t === type) || 'png'; }; /** * Handles and validates resources for export. * * @param {Object|string} resources - The resources to be handled. Can be either * a JSON object, stringified JSON or a path to a JSON file. * @param {boolean} allowFileResources - Whether to allow loading resources from * files. * * @returns {Object|undefined} - The handled resources or undefined if no valid * resources are found. */ export const handleResources = (resources = false, allowFileResources) => { const allowedProps = ['js', 'css', 'files']; let handledResources = resources; let correctResources = false; // Try to load resources from a file if (allowFileResources && resources.endsWith('.json')) { try { handledResources = isCorrectJSON(readFileSync(resources, 'utf8')); } catch (error) { return logWithStack(2, error, `[cli] No resources found.`); } } else { // Try to get JSON handledResources = isCorrectJSON(resources); // Get rid of the files section if (handledResources && !allowFileResources) { delete handledResources.files; } } // Filter from unnecessary properties for (const propName in handledResources) { if (!allowedProps.includes(propName)) { delete handledResources[propName]; } else if (!correctResources) { correctResources = true; } } // Check if at least one of allowed properties is present if (!correctResources) { return log(3, `[cli] No resources found.`); } // Handle files section if (handledResources.files) { handledResources.files = handledResources.files.map((item) => item.trim()); if (!handledResources.files || handledResources.files.length <= 0) { delete handledResources.files; } } // Return resources return handledResources; }; /** * Validates and parses JSON data. Checks if provided data is or can * be a correct JSON. If a primitive is provided, it is stringified and returned. * * @param {Object|string} data - The JSON data to be validated and parsed. * @param {boolean} toString - Whether to return a stringified representation * of the parsed JSON. * * @returns {Object|string|boolean} - The parsed JSON object, stringified JSON, * or false if validation fails. */ export function isCorrectJSON(data, toString) { try { // Get the string representation if not already before parsing const parsedData = JSON.parse( typeof data !== 'string' ? JSON.stringify(data) : data ); // Return a stringified representation of a JSON if required if (typeof parsedData !== 'string' && toString) { return JSON.stringify(parsedData); } // Return a JSON return parsedData; } catch { return false; } } /** * Checks if the given item is an object. * * @param {any} item - The item to be checked. * * @returns {boolean} - True if the item is an object, false otherwise. */ export const isObject = (item) => typeof item === 'object' && !Array.isArray(item) && item !== null; /** * Checks if the given object is empty. * * @param {Object} item - The object to be checked. * * @returns {boolean} - True if the object is empty, false otherwise. */ export const isObjectEmpty = (item) => typeof item === 'object' && !Array.isArray(item) && item !== null && Object.keys(item).length === 0; /** * Checks if a private IP range URL is found in the given string. * * @param {string} item - The string to be checked for a private IP range URL. * * @returns {boolean} - True if a private IP range URL is found, false * otherwise. */ export const isPrivateRangeUrlFound = (item) => { const regexPatterns = [ /xlink:href="(?:http:\/\/|https:\/\/)?localhost\b/, /xlink:href="(?:http:\/\/|https:\/\/)?10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, /xlink:href="(?:http:\/\/|https:\/\/)?127\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, /xlink:href="(?:http:\/\/|https:\/\/)?172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}\b/, /xlink:href="(?:http:\/\/|https:\/\/)?192\.168\.\d{1,3}\.\d{1,3}\b/ ]; return regexPatterns.some((pattern) => pattern.test(item)); }; /** * Creates a deep copy of the given object or array. * * @param {Object|Array} obj - The object or array to be deeply copied. * * @returns {Object|Array} - The deep copy of the provided object or array. */ export const deepCopy = (obj) => { if (obj === null || typeof obj !== 'object') { return obj; } const copy = Array.isArray(obj) ? [] : {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { copy[key] = deepCopy(obj[key]); } } return copy; }; /** * Converts the provided options object to a JSON-formatted string with the * option to preserve functions. * * @param {Object} options - The options object to be converted to a string. * @param {boolean} allowFunctions - If set to true, functions are preserved * in the output. * * @returns {string} - The JSON-formatted string representing the options. */ export const optionsStringify = (options, allowFunctions) => { const replacerCallback = (name, value) => { if (typeof value === 'string') { value = value.trim(); // If allowFunctions is set to true, preserve functions if ( (value.startsWith('function(') || value.startsWith('function (')) && value.endsWith('}') ) { value = allowFunctions ? `EXP_FUN${(value + '').replaceAll(/\n|\t|\r/g, ' ')}EXP_FUN` : undefined; } } return typeof value === 'function' ? `EXP_FUN${(value + '').replaceAll(/\n|\t|\r/g, ' ')}EXP_FUN` : value; }; // Stringify options and if required, replace special functions marks return JSON.stringify(options, replacerCallback).replaceAll( /"EXP_FUN|EXP_FUN"/g, '' ); }; /** * Prints the Highcharts Export Server logo and version information. * * @param {boolean} noLogo - If true, only prints version information without * the logo. */ export const printLogo = (noLogo) => { // Get package version either from env or from package.json const packageVersion = JSON.parse( readFileSync(join(__dirname, 'package.json')) ).version; // Print text only if (noLogo) { console.log(`Starting Highcharts Export Server v${packageVersion}...`); return; } // Print the logo console.log( readFileSync(__dirname + '/msg/startup.msg').toString().bold.yellow, `v${packageVersion}\n`.bold ); }; /** * Prints the usage information for CLI arguments. If required, it can list * properties recursively */ export function printUsage() { const pad = 48; const readme = 'https://github.com/highcharts/node-export-server#readme'; // Display readme information console.log( '\nUsage of CLI arguments:'.bold, '\n------', `\nFor more detailed information, visit the readme at: ${readme.bold.yellow}.` ); const cycleCategories = (options) => { for (const [name, option] of Object.entries(options)) { // If category has more levels, go further if (!Object.prototype.hasOwnProperty.call(option, 'value')) { cycleCategories(option); } else { let descName = ` --${option.cliName || name} ${ ('<' + option.type + '>').green } `; if (descName.length < pad) { for (let i = descName.length; i < pad; i++) { descName += '.'; } } // Display correctly aligned messages console.log( descName, option.description, `[Default: ${option.value.toString().bold}]`.blue ); } } }; // Cycle through options of each categories and display the usage info Object.keys(defaultConfig).forEach((category) => { // Only puppeteer and highcharts categories cannot be configured through CLI if (!['puppeteer', 'highcharts'].includes(category)) { console.log(`\n${category.toUpperCase()}`.red); cycleCategories(defaultConfig[category]); } }); console.log('\n'); } /** * Rounds a number to the specified precision. * * @param {number} value - The number to be rounded. * @param {number} precision - The number of decimal places to round to. * * @returns {number} - The rounded number. */ export const roundNumber = (value, precision = 1) => { const multiplier = Math.pow(10, precision || 0); return Math.round(+value * multiplier) / multiplier; }; /** * Converts a value to a boolean. * * @param {any} item - The value to be converted to a boolean. * * @returns {boolean} - The boolean representation of the input value. */ export const toBoolean = (item) => ['false', 'undefined', 'null', 'NaN', '0', ''].includes(item) ? false : !!item; /** * Wraps custom code to execute it safely. * * @param {string} customCode - The custom code to be wrapped. * @param {boolean} allowFileResources - Flag to allow loading code from a file. * * @returns {string|boolean} - The wrapped custom code or false if wrapping * fails. */ export const wrapAround = (customCode, allowFileResources) => { if (customCode && typeof customCode === 'string') { customCode = customCode.trim(); if (customCode.endsWith('.js')) { return allowFileResources ? wrapAround(readFileSync(customCode, 'utf8')) : false; } else if ( customCode.startsWith('function()') || customCode.startsWith('function ()') || customCode.startsWith('()=>') || customCode.startsWith('() =>') ) { return `(${customCode})()`; } return customCode.replace(/;$/, ''); } }; /** * Utility to measure elapsed time using the Node.js process.hrtime() method. * * @returns {function(): number} - A function to calculate the elapsed time * in milliseconds. */ export const measureTime = () => { const start = process.hrtime.bigint(); return () => Number(process.hrtime.bigint() - start) / 1000000; }; export default { __dirname, clearText, expBackoff, fixType, handleResources, isCorrectJSON, isObject, isObjectEmpty, isPrivateRangeUrlFound, optionsStringify, printLogo, printUsage, roundNumber, toBoolean, wrapAround, measureTime };