UNPKG

convert-svg-core

Version:

Supports converting SVG into another format using headless Chromium

434 lines 15.7 kB
/* * Copyright (C) 2025 neocotic * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ var _a; import { readFile, writeFile } from "node:fs/promises"; import { basename, dirname, extname, join, resolve } from "node:path"; import { load as loadHtml } from "cheerio"; import fileUrl from "file-url"; import { launch as launchBrowser, } from "puppeteer-core"; import { file as tmpFile } from "tmp"; /** * An implementation of {@link IConverter}. */ export class Converter { static #allowedAttributeNames = new Set([ // Core "height", "preserveAspectRatio", "viewBox", "width", "x", "xmlns", "y", // Conditional Processing "requiredExtensions", "systemLanguage", // Presentation "clip-path", "clip-rule", "color", "color-interpolation", "cursor", "display", "fill", "fill-opacity", "fill-rule", "filter", "mask", "opacity", "overflow", "pointer-events", "shape-rendering", "stroke", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-opacity", "stroke-width", "style", "transform", "vector-effect", "visibility", // XML "xml:lang", "xmlns", "xmlns:xlink", ]); static #allowedDeprecatedAttributeNames = new Set([ // Core "baseProfile", "version", "zoomAndPan", // Conditional Processing "requiredFeatures", // Presentation "clip", "color-rendering", "enable-background", // XML "xml:base", "xml:space", ]); /** * Creates an instance of {@link Converter} using the `options` provided. * * A {@link BrowserContext} is created from the either {@link IConverterOptions#browser}, if specified, or a new * {@link Browser} instance launched using {@link IConverterOptions#launch}. * * An error will occur if neither of the above options is specified. * * @param options The options to be used. * @return A newly created {@link Converter} instance. */ static async create(options) { const { browser: browserOption, launch: launchOptions, ...converterOptions } = options; const browser = browserOption ?? (await launchBrowser(_a.#handleExecutablePath({ ..._a.parseEnvLaunchOptions(), ...launchOptions, }))); const browserContext = await browser.createBrowserContext(); return new _a({ browser, browserContext, ...converterOptions, }); } /** * TODO: Document * * @return */ static parseEnvLaunchOptions() { // TODO: Add zod validation? const rawEnvOptions = process.env.CONVERT_SVG_LAUNCH_OPTIONS; return rawEnvOptions ? JSON.parse(rawEnvOptions) : undefined; } static #handleExecutablePath(options) { const { executablePath, ...launchOptions } = options; switch (typeof executablePath) { case "string": return { ...launchOptions, executablePath }; case "function": return { ...launchOptions, executablePath: executablePath(launchOptions), }; default: return launchOptions; } } static #isAttributeAllowed(attributeName, options) { return (_a.#allowedAttributeNames.has(attributeName) || (!!options.allowDeprecatedAttributes && _a.#allowedDeprecatedAttributeNames.has(attributeName))); } static #isValidRounding(value) { return (typeof value === "string" && ["ceil", "floor", "round"].includes(value)); } static #parseBaseUrlOptions(baseFile, baseUrl, defaultBaseUrlSupplier) { if (baseFile != null && baseUrl != null) { throw new Error("Both baseFile and baseUrl options specified. Use only one"); } const value = typeof baseFile === "string" ? fileUrl(baseFile) : baseUrl; return value || fileUrl(defaultBaseUrlSupplier()); } static #parseBooleanOption(value, defaultValue) { return typeof value === "boolean" ? value : defaultValue; } static #parseNumericOption(value, defaultValue) { if (typeof value === "string") { return parseInt(value, 10); } return typeof value === "number" ? value : defaultValue; } static #parseRoundingOption(value, defaultValue) { if (_a.#isValidRounding(value)) { return value; } return defaultValue; } static #roundDimension(dimension, rounding) { switch (rounding) { case "ceil": return Math.ceil(dimension); case "floor": return Math.floor(dimension); case "round": return Math.round(dimension); default: throw new Error(`Unexpected IConverterRounding: "${rounding}"`); } } static #roundDimensions(dimensions, rounding) { return { height: _a.#roundDimension(dimensions.height, rounding), width: _a.#roundDimension(dimensions.width, rounding), }; } static async #setDimensions({ page }, dimensions) { if (typeof dimensions.width !== "number" && typeof dimensions.height !== "number") { return; } await page.evaluate(({ width, height }) => { const el = document.querySelector("svg"); if (!el) { return; } if (typeof width === "number") { el.setAttribute("width", `${width}px`); } else { el.removeAttribute("width"); } if (typeof height === "number") { el.setAttribute("height", `${height}px`); } else { el.removeAttribute("height"); } }, dimensions); } #browser; #browserContext; #closeBehavior; #closed; #pageOptions; #provider; #tempFiles = {}; constructor(options) { this.#browser = options.browser; this.#browserContext = options.browserContext; this.#closed = !options.browser.connected; this.#closeBehavior = options.closeBehavior || "close"; this.#pageOptions = { ...options.page }; this.#provider = options.provider; } async convert(input, options) { this.#validate(); const parsedOptions = this.#parseConvertOptions(options, process.cwd); return await this.#convert(input, parsedOptions); } async convertFile(inputFilePath, options) { this.#validate(); const parsedOptions = this.#parseConvertFileOptions(inputFilePath, options); const input = await readFile(inputFilePath); const output = await this.#convert(input, parsedOptions); await writeFile(parsedOptions.outputFilePath, output); return parsedOptions.outputFilePath; } async close() { if (this.#closed) { return; } this.#closed = true; switch (this.#closeBehavior) { case "close": await this.#browser.close(); break; case "disconnect": await this.#browserContext.close(); await this.#browser.disconnect(); break; case "none": await this.#browserContext.close(); break; default: throw new Error(`Unexpected IConverterCloseBehavior: "${this.#closeBehavior}"`); } Object.values(this.#tempFiles).forEach((tempFile) => tempFile.cleanup()); this.#tempFiles = {}; } async #closeContext(context) { context.tempFile.cleanup(); delete this.#tempFiles[context.tempFile.path]; await context.page.close(); } async #convert(input, options) { input = Buffer.isBuffer(input) ? input.toString("utf8") : input; const provider = this.#provider; const $ = loadHtml(input, null, false); const $svg = $("svg:first"); this.#sanitize($svg, options); $svg.find("svg").each((_i, svg) => { this.#sanitize($(svg), options); }); const svg = $svg.prop("outerHTML"); if (!svg) { throw new Error("SVG element not found in input. Check the SVG input"); } const html = `<!DOCTYPE html> <html> <head> <base href="${options.baseUrl}"> <meta charset="utf-8"> <style> * { margin: 0; padding: 0; } html { background-color: ${provider.getBackgroundColor(options)}; } </style> </head> <body>${svg}</body> </html>`; const context = await this.#createContext(html); try { await _a.#setDimensions(context, options); let dimensions = await this.#getDimensions(context, options); if (options.scale !== 1) { dimensions = _a.#roundDimensions({ height: dimensions.height * options.scale, width: dimensions.width * options.scale, }, options.rounding); await _a.#setDimensions(context, dimensions); } await context.page.setViewport(dimensions); return Buffer.from(await context.page.screenshot({ clip: { x: 0, y: 0, ...dimensions }, ...provider.getScreenshotOptions(options), type: provider.format, })); } finally { await this.#closeContext(context); } } async #createContext(html) { const [page, tempFile] = await Promise.all([ this.#browserContext.newPage(), this.#createTempFile(), ]); await writeFile(tempFile.path, html); await page.goto(fileUrl(tempFile.path), this.#pageOptions); return { page, tempFile }; } #createTempFile() { return new Promise((resolve, reject) => tmpFile({ prefix: "convert-svg-", postfix: ".html" }, (error, filePath, _fd, cleanup) => { if (error) { reject(error); } else { const tempFile = { path: filePath, cleanup }; this.#tempFiles[filePath] = tempFile; resolve(tempFile); } })); } async #getDimensions({ page }, options) { const dimensions = await page.evaluate(() => { const el = document.querySelector("svg"); if (!el) { return null; } const parseAttributeDimension = (attributeName) => { const attributeValue = el.getAttribute(attributeName); if (!attributeValue || attributeValue.endsWith("%")) { return null; } const dimension = parseFloat(attributeValue); if (Number.isNaN(dimension)) { return null; } if (attributeValue.endsWith("pt")) { return dimension * 1.33333; } return dimension; }; const width = parseAttributeDimension("width"); const height = parseAttributeDimension("height"); if (width && height) { return { width, height }; } const viewBoxWidth = el.viewBox.animVal.width; const viewBoxHeight = el.viewBox.animVal.height; if (width && viewBoxHeight) { return { width, height: (width * viewBoxHeight) / viewBoxWidth, }; } if (height && viewBoxWidth) { return { width: (height * viewBoxWidth) / viewBoxHeight, height, }; } return null; }); if (!dimensions) { throw new Error("Unable to derive width and height from SVG. Consider specifying corresponding options"); } return _a.#roundDimensions(dimensions, options.rounding); } #parseConvertFileOptions(inputFilePath, options) { const parsedOptions = this.#parseConvertOptions(options, () => inputFilePath ? resolve(inputFilePath) : process.cwd()); let outputFilePath = options?.outputFilePath; if (!outputFilePath) { const extension = `.${this.#provider.extension}`; const outputDirPath = dirname(inputFilePath); const outputFileName = `${basename(inputFilePath, extname(inputFilePath))}${extension}`; outputFilePath = join(outputDirPath, outputFileName); } return { ...parsedOptions, outputFilePath, }; } #parseConvertOptions(options, defaultBaseUrlSupplier) { return this.#provider.parseConverterOptions(options, { allowDeprecatedAttributes: _a.#parseBooleanOption(options?.allowDeprecatedAttributes, true), background: options?.background, baseUrl: _a.#parseBaseUrlOptions(options?.baseFile, options?.baseUrl, defaultBaseUrlSupplier), height: _a.#parseNumericOption(options?.height), rounding: _a.#parseRoundingOption(options?.rounding, "round"), scale: _a.#parseNumericOption(options?.scale, 1), width: _a.#parseNumericOption(options?.width), }); } #sanitize($svg, options) { const attributeNames = Object.keys($svg.attr() || {}); for (const attributeName of attributeNames) { if (!_a.#isAttributeAllowed(attributeName, options)) { $svg.removeAttr(attributeName); } } } #validate() { if (this.#closed) { throw new Error("Converter has been closed. A new Converter must be created"); } if (!this.#browser.connected) { throw new Error("Converter has lost connection to the browser. A new Converter must be created"); } } get closed() { return this.#closed; } get provider() { return this.#provider; } } _a = Converter; //# sourceMappingURL=converter.js.map