convert-svg-core
Version:
Supports converting SVG into another format using headless Chromium
441 lines • 16.1 kB
JavaScript
;
/*
* 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 __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Converter = void 0;
const promises_1 = require("node:fs/promises");
const node_path_1 = require("node:path");
const cheerio_1 = require("cheerio");
const file_url_1 = __importDefault(require("file-url"));
const puppeteer_core_1 = require("puppeteer-core");
const tmp_1 = require("tmp");
/**
* An implementation of {@link IConverter}.
*/
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 (0, puppeteer_core_1.launch)(_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" ? (0, file_url_1.default)(baseFile) : baseUrl;
return value || (0, file_url_1.default)(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 (0, promises_1.readFile)(inputFilePath);
const output = await this.#convert(input, parsedOptions);
await (0, promises_1.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 $ = (0, cheerio_1.load)(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 (0, promises_1.writeFile)(tempFile.path, html);
await page.goto((0, file_url_1.default)(tempFile.path), this.#pageOptions);
return { page, tempFile };
}
#createTempFile() {
return new Promise((resolve, reject) => (0, tmp_1.file)({ 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 ? (0, node_path_1.resolve)(inputFilePath) : process.cwd());
let outputFilePath = options?.outputFilePath;
if (!outputFilePath) {
const extension = `.${this.#provider.extension}`;
const outputDirPath = (0, node_path_1.dirname)(inputFilePath);
const outputFileName = `${(0, node_path_1.basename)(inputFilePath, (0, node_path_1.extname)(inputFilePath))}${extension}`;
outputFilePath = (0, node_path_1.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;
}
}
exports.Converter = Converter;
_a = Converter;
//# sourceMappingURL=converter.js.map