diff --git a/README.md b/README.md index d3cc776..874ccd3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ To use svg-to-img in your project, run: npm install svg-to-img -S ``` -Note: When you install svg-to-img, it downloads a recent version of Chromium (~170Mb Mac, ~282Mb Linux, ~280Mb Win) that is guaranteed to work with the library. +Note: When you install svg-to-img, it downloads a recent version of Chromium (~170Mb Mac, ~282Mb Linux, ~280Mb Win) that is guaranteed to work with the library. If you want to skip downloading Chromium and instead [connect to a browser websocket endpoint](#svgtoimgconnectoptions), set the [`PUPPETEER_SKIP_CHROMIUM_DOWNLOAD`](https://github.com/puppeteer/puppeteer/blob/v2.0.0/docs/api.md#environment-variables) environment variable. #### Debian @@ -92,6 +92,21 @@ const svgToImg = require("svg-to-img"); })(); ``` +**Example** - connect to Puppeteer `browserWSEndpoint`: + +```javascript +const svgToImg = require("svg-to-img"); +const convert = svgToImg.connect({ + browserWSEndpoint: "ws://localhost:3000" +}); + +(async () => { + const image = await convert.from("").toPng(); + + console.log(image); +})(); +``` + ## API Documentation ### svgToImg.from(svg) @@ -100,6 +115,12 @@ const svgToImg = require("svg-to-img"); The method returns a svg instance based on the given argument. +### svgToImg.connect(options) +- `options` <[Object]> Pupetteer [connect options](https://github.com/puppeteer/puppeteer/blob/master/docs/api.md#puppeteerconnectoptions) object which will typically contain a `browserWSEndpoint` property. +- returns: <[SvgToImg]> an instance which is connected with the specified Puppeteer endpoint. + +Use this method if you want to connect to a running Puppeteer endpoint (see [here](https://github.com/etienne-martin/svg-to-img/issues/9)). Note that the actual connection is established lazily; this means this method will always return, even when the given `browserWSEndpoint` cannot be reached -- in this case, errors will be thrown later when calling one of the `to` functions. + ### svg.to([options]) - `options` <[Object]> Options object which might have the following properties: - `path` <[string]> The file path to save the image to. The image type will be inferred from file extension. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the image won't be saved to the disk. @@ -152,6 +173,7 @@ Execute `npm run test` and update the [tests](https://github.com/etienne-martin/ ## Authors * **Etienne Martin** - *Initial work* - [etiennemartin.ca](http://etiennemartin.ca/) +* **Philipp Katz** - *Connect to browser endpoint* - [gitlab.com/qqilihq](https://gitlab.com/qqilihq) ## License diff --git a/dist/helpers.js b/dist/helpers.js index 8883174..6dfa37c 100644 --- a/dist/helpers.js +++ b/dist/helpers.js @@ -93,6 +93,14 @@ exports.renderSvg = async (svg, options) => { img.addEventListener("load", onLoad); img.addEventListener("error", onError); document.body.appendChild(img); - img.src = "data:image/svg+xml;charset=utf8," + svg; + // need to supply this base64-encoded, otherwise + // we get the "Malformed SVG" error (see above) + // when running remotely via WebSocket connection; + // note that `btoa` only works on ASCII characters, + // that’s why we need the `unescape(encodeURIComponent(…))` + // workaround -- see here: + // https://stackoverflow.com/a/26603875 + const base64Svg = btoa(unescape(encodeURIComponent(svg))); + img.src = "data:image/svg+xml;base64," + base64Svg; }); }; diff --git a/dist/index.d.ts b/dist/index.d.ts index 197d28f..ec3990e 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,8 +1,31 @@ /// -import { IOptions, IShorthandOptions } from "./typings"; -export declare const from: (svg: string | Buffer) => { - to: (options: IOptions) => Promise; - toPng: (options?: IShorthandOptions | undefined) => Promise; - toJpeg: (options?: IShorthandOptions | undefined) => Promise; - toWebp: (options?: IShorthandOptions | undefined) => Promise; -}; +import * as puppeteer from "puppeteer"; +import { IOptions, IShorthandOptions, IConnectOptions } from "./typings"; +export declare class BrowserSource { + private readonly factory; + private queue; + private browserDestructionTimeout; + private browserInstance; + private browserState; + constructor(factory: () => Promise); + getBrowser(): Promise; + scheduleBrowserForDestruction(): void; + private executeQueuedRequests; +} +export declare class Svg { + private readonly svg; + private browserSource; + constructor(svg: Buffer | string, browserSource: BrowserSource); + to(options: IOptions): Promise; + toPng(options?: IShorthandOptions): Promise; + toJpeg(options?: IShorthandOptions): Promise; + toWebp(options?: IShorthandOptions): Promise; + private convertSvg; +} +export declare class SvgToImg { + private readonly browserSource; + constructor(browserSource: BrowserSource); + from(svg: Buffer | string): Svg; +} +export declare const from: (svg: string | Buffer) => Svg; +export declare const connect: (options: IConnectOptions) => SvgToImg; diff --git a/dist/index.js b/dist/index.js index c909974..675c85f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,113 +3,148 @@ Object.defineProperty(exports, "__esModule", { value: true }); const puppeteer = require("puppeteer"); const helpers_1 = require("./helpers"); const constants_1 = require("./constants"); -const queue = []; -let browserDestructionTimeout; -let browserInstance; -let browserState = "closed"; -const executeQueuedRequests = (browser) => { - for (const resolve of queue) { - resolve(browser); +class BrowserSource { + constructor(factory) { + this.factory = factory; + this.queue = []; + this.browserState = "closed"; } - // Clear items from the queue - queue.length = 0; -}; -const getBrowser = async () => { - return new Promise(async (resolve) => { - clearTimeout(browserDestructionTimeout); - if (browserState === "closed") { - // Browser is closed - queue.push(resolve); - browserState = "opening"; - browserInstance = await puppeteer.launch(constants_1.config.puppeteer); - browserState = "open"; - return executeQueuedRequests(browserInstance); - } + async getBrowser() { + return new Promise(async (resolve, reject) => { + /* istanbul ignore if */ + if (this.browserDestructionTimeout) { + clearTimeout(this.browserDestructionTimeout); + } + /* istanbul ignore else */ + if (this.browserState === "closed") { + // Browser is closed + this.queue.push(resolve); + this.browserState = "opening"; + try { + this.browserInstance = await this.factory(); + this.browserState = "open"; + return this.executeQueuedRequests(this.browserInstance); + } + catch (error) { + this.browserState = "closed"; + return reject(error); + } + } + /* istanbul ignore next */ + if (this.browserState === "opening") { + // Queue request and wait for the browser to open + return this.queue.push(resolve); + } + /* istanbul ignore next */ + if (this.browserState === "open") { + // Browser is already open + if (this.browserInstance) { + return resolve(this.browserInstance); + } + } + }); + } + ; + scheduleBrowserForDestruction() { /* istanbul ignore if */ - if (browserState === "opening") { - // Queue request and wait for the browser to open - return queue.push(resolve); + if (this.browserDestructionTimeout) { + clearTimeout(this.browserDestructionTimeout); } - /* istanbul ignore next */ - if (browserState === "open") { - // Browser is already open - if (browserInstance) { - return resolve(browserInstance); + this.browserDestructionTimeout = setTimeout(async () => { + /* istanbul ignore next */ + if (this.browserInstance) { + this.browserState = "closed"; + await this.browserInstance.close(); } + }, 500); + } + ; + executeQueuedRequests(browser) { + for (const resolve of this.queue) { + resolve(browser); } - }); -}; -const scheduleBrowserForDestruction = () => { - clearTimeout(browserDestructionTimeout); - browserDestructionTimeout = setTimeout(async () => { - /* istanbul ignore next */ - if (browserInstance) { - browserState = "closed"; - await browserInstance.close(); + // Clear items from the queue + this.queue.length = 0; + } + ; +} +exports.BrowserSource = BrowserSource; +; +class Svg { + constructor(svg, browserSource) { + this.svg = svg; + this.browserSource = browserSource; + } + async to(options) { + return this.convertSvg(this.svg, options, this.browserSource); + } + ; + async toPng(options) { + return this.convertSvg(this.svg, Object.assign({}, constants_1.defaultPngShorthandOptions, options), this.browserSource); + } + ; + async toJpeg(options) { + return this.convertSvg(this.svg, Object.assign({}, constants_1.defaultJpegShorthandOptions, options), this.browserSource); + } + ; + async toWebp(options) { + return this.convertSvg(this.svg, Object.assign({}, constants_1.defaultWebpShorthandOptions, options), this.browserSource); + } + ; + async convertSvg(inputSvg, passedOptions, browserSource) { + const svg = Buffer.isBuffer(inputSvg) ? inputSvg.toString("utf8") : inputSvg; + const options = Object.assign({}, constants_1.defaultOptions, passedOptions); + const browser = await browserSource.getBrowser(); + const page = (await browser.pages())[0]; + // ⚠️ Offline mode is enabled to prevent any HTTP requests over the network + await page.setOfflineMode(true); + // Infer the file type from the file path if no type is provided + if (!passedOptions.type && options.path) { + const fileType = helpers_1.getFileTypeFromPath(options.path); + if (constants_1.config.supportedImageTypes.includes(fileType)) { + options.type = fileType; + } } - }, 500); -}; -const convertSvg = async (inputSvg, passedOptions) => { - const svg = Buffer.isBuffer(inputSvg) ? inputSvg.toString("utf8") : inputSvg; - const options = Object.assign({}, constants_1.defaultOptions, passedOptions); - const browser = await getBrowser(); - const page = (await browser.pages())[0]; - // ⚠️ Offline mode is enabled to prevent any HTTP requests over the network - await page.setOfflineMode(true); - // Infer the file type from the file path if no type is provided - if (!passedOptions.type && options.path) { - const fileType = helpers_1.getFileTypeFromPath(options.path); - if (constants_1.config.supportedImageTypes.includes(fileType)) { - options.type = fileType; + const base64 = await page.evaluate(helpers_1.stringifyFunction(helpers_1.renderSvg, svg, { + width: options.width, + height: options.height, + type: options.type, + quality: options.quality, + background: options.background, + clip: options.clip, + jpegBackground: constants_1.config.jpegBackground + })); + browserSource.scheduleBrowserForDestruction(); + const buffer = Buffer.from(base64, "base64"); + if (options.path) { + await helpers_1.writeFileAsync(options.path, buffer); } + if (options.encoding === "base64") { + return base64; + } + if (!options.encoding) { + return buffer; + } + return buffer.toString(options.encoding); } - const base64 = await page.evaluate(helpers_1.stringifyFunction(helpers_1.renderSvg, svg, { - width: options.width, - height: options.height, - type: options.type, - quality: options.quality, - background: options.background, - clip: options.clip, - jpegBackground: constants_1.config.jpegBackground - })); - scheduleBrowserForDestruction(); - const buffer = Buffer.from(base64, "base64"); - if (options.path) { - await helpers_1.writeFileAsync(options.path, buffer); - } - if (options.encoding === "base64") { - return base64; + ; +} +exports.Svg = Svg; +class SvgToImg { + constructor(browserSource) { + this.browserSource = browserSource; } - if (!options.encoding) { - return buffer; + from(svg) { + return new Svg(svg, this.browserSource); } - return buffer.toString(options.encoding); -}; -const to = (svg) => { - return async (options) => { - return convertSvg(svg, options); - }; -}; -const toPng = (svg) => { - return async (options) => { - return convertSvg(svg, Object.assign({}, constants_1.defaultPngShorthandOptions, options)); - }; -}; -const toJpeg = (svg) => { - return async (options) => { - return convertSvg(svg, Object.assign({}, constants_1.defaultJpegShorthandOptions, options)); - }; -}; -const toWebp = (svg) => { - return async (options) => { - return convertSvg(svg, Object.assign({}, constants_1.defaultWebpShorthandOptions, options)); - }; -}; + ; +} +exports.SvgToImg = SvgToImg; +const defaultBrowserSource = new BrowserSource(async () => puppeteer.launch(constants_1.config.puppeteer)); exports.from = (svg) => { - return { - to: to(svg), - toPng: toPng(svg), - toJpeg: toJpeg(svg), - toWebp: toWebp(svg) - }; + return new SvgToImg(defaultBrowserSource).from(svg); +}; +/* istanbul ignore next */ +exports.connect = (options) => { + return new SvgToImg(new BrowserSource(async () => puppeteer.connect(options))); }; diff --git a/dist/typings/index.d.ts b/dist/typings/index.d.ts index a255078..2a5acbb 100644 --- a/dist/typings/index.d.ts +++ b/dist/typings/index.d.ts @@ -17,3 +17,8 @@ export interface IOptions { export interface IShorthandOptions extends IOptions { type?: never; } +export interface IConnectOptions { + browserWSEndpoint?: string; + browserURL?: string; + ignoreHTTPSErrors?: boolean; +} diff --git a/package-lock.json b/package-lock.json index cb61c43..b6bcc2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,9 +132,9 @@ "dev": true }, "agent-base": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", - "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", "requires": { "es6-promisify": "^5.0.0" } @@ -1405,18 +1405,11 @@ } }, "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "requires": { - "ms": "2.0.0" - }, - "dependencies": { - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - } + "ms": "^2.1.1" } }, "decamelize": { @@ -1629,13 +1622,13 @@ } }, "es6-promise": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.5.tgz", - "integrity": "sha512-n6wvpdE43VFtJq+lUDYDBFUwV8TZbuGXLV4D6wKafg13ldznKsyEvatubnmUe31zcvelSzOHF+XbaT+Bl9ObDg==" + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" }, "es6-promisify": { "version": "5.0.0", - "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "requires": { "es6-promise": "^4.0.3" @@ -1800,6 +1793,21 @@ "debug": "2.6.9", "mkdirp": "0.5.1", "yauzl": "2.4.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } } }, "extsprintf": { @@ -2753,11 +2761,11 @@ } }, "https-proxy-agent": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz", - "integrity": "sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", + "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", "requires": { - "agent-base": "^4.1.0", + "agent-base": "^4.3.0", "debug": "^3.1.0" }, "dependencies": { @@ -3881,9 +3889,9 @@ } }, "mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz", + "integrity": "sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==" }, "mime-db": { "version": "1.37.0", @@ -4470,18 +4478,18 @@ "dev": true }, "puppeteer": { - "version": "1.1.1", - "resolved": "http://registry.npmjs.org/puppeteer/-/puppeteer-1.1.1.tgz", - "integrity": "sha1-rb8l5J9e8DRDwQq44JqVTKDHv+4=", - "requires": { - "debug": "^2.6.8", - "extract-zip": "^1.6.5", - "https-proxy-agent": "^2.1.0", - "mime": "^1.3.4", - "progress": "^2.0.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-2.0.0.tgz", + "integrity": "sha512-t3MmTWzQxPRP71teU6l0jX47PHXlc4Z52sQv4LJQSZLq1ttkKS2yGM3gaI57uQwZkNaoGd0+HPPMELZkcyhlqA==", + "requires": { + "debug": "^4.1.0", + "extract-zip": "^1.6.6", + "https-proxy-agent": "^3.0.0", + "mime": "^2.0.3", + "progress": "^2.0.1", "proxy-from-env": "^1.0.0", "rimraf": "^2.6.1", - "ws": "^3.0.0" + "ws": "^6.1.0" } }, "qs": { @@ -6166,11 +6174,6 @@ } } }, - "ultron": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", - "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" - }, "union-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", @@ -6468,13 +6471,11 @@ } }, "ws": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", - "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", "requires": { - "async-limiter": "~1.0.0", - "safe-buffer": "~5.1.0", - "ultron": "~1.1.0" + "async-limiter": "~1.0.0" } }, "xml-name-validator": { diff --git a/package.json b/package.json index 4aa9c1d..d1a392f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "name": "Etienne Martin", "url": "http://etiennemartin.ca/" }, + "contributors": [ + "Philipp Katz (https://gitlab.com/qqilihq)" + ], "scripts": { "dev": "tsc --pretty --watch", "lint": "tslint -c tslint.json -p tsconfig.json --fix", @@ -102,6 +105,6 @@ "typescript": "2.9.2" }, "dependencies": { - "puppeteer": "1.1.1" + "puppeteer": "2.0.0" } } diff --git a/src/helpers.ts b/src/helpers.ts index 74740df..a77fac5 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -28,7 +28,7 @@ export const stringifyFunction = (func: any, ...argsArray: any[]) => { export const writeFileAsync = async (path: string, data: Buffer) => { return new Promise((resolve, reject) => { - fs.writeFile(path, data, (err: Error) => { + fs.writeFile(path, data, (err: NodeJS.ErrnoException | null) => { if (err) { return reject(err); } resolve(); @@ -128,6 +128,14 @@ export const renderSvg = async (svg: string, options: { img.addEventListener("error", onError); document.body.appendChild(img); - img.src = "data:image/svg+xml;charset=utf8," + svg; + // need to supply this base64-encoded, otherwise + // we get the "Malformed SVG" error (see above) + // when running remotely via WebSocket connection; + // note that `btoa` only works on ASCII characters, + // that’s why we need the `unescape(encodeURIComponent(…))` + // workaround -- see here: + // https://stackoverflow.com/a/26603875 + const base64Svg = btoa(unescape(encodeURIComponent(svg))); + img.src = "data:image/svg+xml;base64," + base64Svg; }); }; diff --git a/src/index.ts b/src/index.ts index c8c0631..735186a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,138 +1,161 @@ import * as puppeteer from "puppeteer"; import { getFileTypeFromPath, renderSvg, stringifyFunction, writeFileAsync } from "./helpers"; import { config, defaultOptions, defaultPngShorthandOptions, defaultJpegShorthandOptions, defaultWebpShorthandOptions } from "./constants"; -import { IOptions, IShorthandOptions } from "./typings"; - -const queue: Array<(result: puppeteer.Browser) => void> = []; -let browserDestructionTimeout: NodeJS.Timeout; -let browserInstance: puppeteer.Browser|undefined; -let browserState: "closed"|"opening"|"open" = "closed"; - -const executeQueuedRequests = (browser: puppeteer.Browser) => { - for (const resolve of queue) { - resolve(browser); - } - // Clear items from the queue - queue.length = 0; -}; +import { IOptions, IShorthandOptions, IConnectOptions } from "./typings"; -const getBrowser = async (): Promise => { - return new Promise(async (resolve: (result: puppeteer.Browser) => void) => { - clearTimeout(browserDestructionTimeout); +export class BrowserSource { + private queue: Array<(result: puppeteer.Browser) => void> = []; + private browserDestructionTimeout: NodeJS.Timeout | undefined; + private browserInstance: puppeteer.Browser | undefined; + private browserState: "closed" | "opening" | "open" = "closed"; - if (browserState === "closed") { - // Browser is closed - queue.push(resolve); - browserState = "opening"; - browserInstance = await puppeteer.launch(config.puppeteer); - browserState = "open"; + constructor (private readonly factory: () => Promise) {} - return executeQueuedRequests(browserInstance); - } + public async getBrowser (): Promise { + return new Promise(async (resolve: (result: puppeteer.Browser) => void, reject: (err: any) => void) => { + /* istanbul ignore if */ + if (this.browserDestructionTimeout) { + clearTimeout(this.browserDestructionTimeout); + } - /* istanbul ignore if */ - if (browserState === "opening") { - // Queue request and wait for the browser to open - return queue.push(resolve); - } + /* istanbul ignore else */ + if (this.browserState === "closed") { + // Browser is closed + this.queue.push(resolve); + this.browserState = "opening"; + try { + this.browserInstance = await this.factory(); + this.browserState = "open"; + + return this.executeQueuedRequests(this.browserInstance); + } catch (error) { + this.browserState = "closed"; + + return reject(error); + } + } + + /* istanbul ignore next */ + if (this.browserState === "opening") { + // Queue request and wait for the browser to open + return this.queue.push(resolve); + } - /* istanbul ignore next */ - if (browserState === "open") { - // Browser is already open - if (browserInstance) { - return resolve(browserInstance); + /* istanbul ignore next */ + if (this.browserState === "open") { + // Browser is already open + if (this.browserInstance) { + return resolve(this.browserInstance); + } } + }); + }; + + public scheduleBrowserForDestruction () { + /* istanbul ignore if */ + if (this.browserDestructionTimeout) { + clearTimeout(this.browserDestructionTimeout); } - }); -}; + this.browserDestructionTimeout = setTimeout(async () => { + /* istanbul ignore next */ + if (this.browserInstance) { + this.browserState = "closed"; + await this.browserInstance.close(); + } + }, 500); + }; -const scheduleBrowserForDestruction = () => { - clearTimeout(browserDestructionTimeout); - browserDestructionTimeout = setTimeout(async () => { - /* istanbul ignore next */ - if (browserInstance) { - browserState = "closed"; - await browserInstance.close(); + private executeQueuedRequests (browser: puppeteer.Browser) { + for (const resolve of this.queue) { + resolve(browser); } - }, 500); + // Clear items from the queue + this.queue.length = 0; + }; }; -const convertSvg = async (inputSvg: Buffer|string, passedOptions: IOptions): Promise => { - const svg = Buffer.isBuffer(inputSvg) ? (inputSvg as Buffer).toString("utf8") : inputSvg; - const options = {...defaultOptions, ...passedOptions}; - const browser = await getBrowser(); - const page = (await browser.pages())[0]; +export class Svg { - // ⚠️ Offline mode is enabled to prevent any HTTP requests over the network - await page.setOfflineMode(true); + constructor (private readonly svg: Buffer|string, private browserSource: BrowserSource) {} - // Infer the file type from the file path if no type is provided - if (!passedOptions.type && options.path) { - const fileType = getFileTypeFromPath(options.path); + public async to (options: IOptions): Promise { + return this.convertSvg(this.svg, options, this.browserSource); + }; - if (config.supportedImageTypes.includes(fileType)) { - options.type = fileType as IOptions["type"]; - } - } + public async toPng (options?: IShorthandOptions): Promise { + return this.convertSvg(this.svg, {...defaultPngShorthandOptions, ...options}, this.browserSource); + }; - const base64 = await page.evaluate(stringifyFunction(renderSvg, svg, { - width: options.width, - height: options.height, - type: options.type, - quality: options.quality, - background: options.background, - clip: options.clip, - jpegBackground: config.jpegBackground - })); + public async toJpeg (options?: IShorthandOptions): Promise { + return this.convertSvg(this.svg, {...defaultJpegShorthandOptions, ...options}, this.browserSource); + }; - scheduleBrowserForDestruction(); + public async toWebp (options?: IShorthandOptions): Promise { + return this.convertSvg(this.svg, {...defaultWebpShorthandOptions, ...options}, this.browserSource); + }; - const buffer = Buffer.from(base64, "base64"); + private async convertSvg (inputSvg: Buffer|string, passedOptions: IOptions, browserSource: BrowserSource): Promise { + const svg = Buffer.isBuffer(inputSvg) ? (inputSvg as Buffer).toString("utf8") : inputSvg; + const options = {...defaultOptions, ...passedOptions}; + const browser = await browserSource.getBrowser(); + const page = (await browser.pages())[0]; - if (options.path) { - await writeFileAsync(options.path, buffer); - } + // ⚠️ Offline mode is enabled to prevent any HTTP requests over the network + await page.setOfflineMode(true); - if (options.encoding === "base64") { - return base64; - } + // Infer the file type from the file path if no type is provided + if (!passedOptions.type && options.path) { + const fileType = getFileTypeFromPath(options.path); - if (!options.encoding) { - return buffer; - } + if (config.supportedImageTypes.includes(fileType)) { + options.type = fileType as IOptions["type"]; + } + } - return buffer.toString(options.encoding); -}; + const base64 = await page.evaluate(stringifyFunction(renderSvg, svg, { + width: options.width, + height: options.height, + type: options.type, + quality: options.quality, + background: options.background, + clip: options.clip, + jpegBackground: config.jpegBackground + })); -const to = (svg: Buffer|string) => { - return async (options: IOptions): Promise => { - return convertSvg(svg, options); - }; -}; + browserSource.scheduleBrowserForDestruction(); -const toPng = (svg: Buffer|string) => { - return async (options?: IShorthandOptions): Promise => { - return convertSvg(svg, {...defaultPngShorthandOptions, ...options}); - }; -}; + const buffer = Buffer.from(base64, "base64"); -const toJpeg = (svg: Buffer|string) => { - return async (options?: IShorthandOptions): Promise => { - return convertSvg(svg, {...defaultJpegShorthandOptions, ...options}); + if (options.path) { + await writeFileAsync(options.path, buffer); + } + + if (options.encoding === "base64") { + return base64; + } + + if (!options.encoding) { + return buffer; + } + + return buffer.toString(options.encoding); }; -}; +} -const toWebp = (svg: Buffer|string) => { - return async (options?: IShorthandOptions): Promise => { - return convertSvg(svg, {...defaultWebpShorthandOptions, ...options}); +export class SvgToImg { + constructor (private readonly browserSource: BrowserSource) {} + public from (svg: Buffer|string) { + return new Svg(svg, this.browserSource); }; -}; +} + +const defaultBrowserSource = new BrowserSource(async () => puppeteer.launch(config.puppeteer)); export const from = (svg: Buffer|string) => { - return { - to: to(svg), - toPng: toPng(svg), - toJpeg: toJpeg(svg), - toWebp: toWebp(svg) - }; -}; + return new SvgToImg(defaultBrowserSource).from(svg); +} + +/* istanbul ignore next */ +export const connect = (options: IConnectOptions) => { + return new SvgToImg(new BrowserSource(async () => puppeteer.connect(options))); +} diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 800ea48..3fa1fe6 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -25,7 +25,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("677e67f0c96c14a79032351d5691bcb2"); + expect(md5(data)).toEqual("242fac8e57f2c24e6865733c78ffd49a"); }); test("From string to image", async () => { @@ -38,7 +38,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("7c310bf3a7267c656d926ce5c8a1c365"); + expect(md5(data)).toEqual("d7f42c771389e20ef07397ebcd3aa5ac"); }); test("Infer file type from file extension", async () => { @@ -53,7 +53,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("677e67f0c96c14a79032351d5691bcb2"); + expect(md5(data)).toEqual("242fac8e57f2c24e6865733c78ffd49a"); }); test("Unknown file extension", async () => { @@ -68,7 +68,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("7c310bf3a7267c656d926ce5c8a1c365"); + expect(md5(data)).toEqual("d7f42c771389e20ef07397ebcd3aa5ac"); }); test("Base64 encoded output", async () => { @@ -81,7 +81,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("d8d4ae8a0824a579c7ca32a7ee93a678"); + expect(md5(data)).toEqual("de0dcfb7ab63a50140c2ab562bd2d942"); }); test("HEX encoded output", async () => { @@ -94,7 +94,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("dd8d4c070bb6db33ad15ace8dd56e61c"); + expect(md5(data)).toEqual("492efd51b52dc7376343202ee67225ca"); }); test("JPEG compression", async () => { @@ -107,7 +107,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("435447377ac681b187d8d55a65ea6b37"); + expect(md5(data)).toEqual("2b6a20b486cf02671c13c0cb9e6bac1d"); }); test("WEBP compression", async () => { @@ -120,7 +120,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("b5a88a19087b48e6aafacf688699ff0a"); + expect(md5(data)).toEqual("aef2e325aa30cd0299ee95c56ff49b9c"); }); test("Custom width and height", async () => { @@ -134,7 +134,7 @@ describe("SVG to image conversion", () => { width: 1000, height: 200 }); - expect(md5(data)).toEqual("35053a5b747abffa7cb1aba24bbbd603"); + expect(md5(data)).toEqual("c683880ab02c0e76243d0abaceffe0c8"); }); test("Custom background color", async () => { @@ -147,7 +147,7 @@ describe("SVG to image conversion", () => { width: 406, height: 206 }); - expect(md5(data)).toEqual("f7c37d538eb948f6609d15d871b3f078"); + expect(md5(data)).toEqual("580c3453b5d102d5b06411c8a1fc4b23"); }); test("Malformed SVG", async () => { @@ -168,7 +168,7 @@ describe("SVG to image conversion", () => { width: 187, height: 150 }); - expect(md5(data)).toEqual("a35bb124b354bb861a6b65118ff16dde"); + expect(md5(data)).toEqual("1b1fe94386407eecc2d083882aa73589"); }); test("Resize responsive SVG (Squashed)", async () => { @@ -182,7 +182,7 @@ describe("SVG to image conversion", () => { width: 300, height: 100 }); - expect(md5(data)).toEqual("f6571224da1e85780c7dc0ea66b7c95c"); + expect(md5(data)).toEqual("cf75cb874aad3174a90936c9b6454cf8"); }); test("Resize responsive SVG (Proportionally)", async () => { @@ -195,7 +195,7 @@ describe("SVG to image conversion", () => { width: 300, height: 241 }); - expect(md5(data)).toEqual("1245ca2a1868e5148d0bbeacc0245d25"); + expect(md5(data)).toEqual("6cd910ca5ebee7366cb6ec51da090b45"); }); test("SVG to PNG shorthand", async () => { @@ -206,7 +206,7 @@ describe("SVG to image conversion", () => { width: 187, height: 150 }); - expect(md5(data)).toEqual("a35bb124b354bb861a6b65118ff16dde"); + expect(md5(data)).toEqual("1b1fe94386407eecc2d083882aa73589"); }); test("SVG to JPEG shorthand", async () => { @@ -217,6 +217,7 @@ describe("SVG to image conversion", () => { width: 187, height: 150 }); + fs.writeFileSync(`${outputDir}/da0a53cd944c1fbd56a64684969882cd.jpg`, data as Buffer); expect(md5(data)).toEqual("da0a53cd944c1fbd56a64684969882cd"); }); @@ -228,7 +229,7 @@ describe("SVG to image conversion", () => { width: 187, height: 150 }); - expect(md5(data)).toEqual("b1080b283475987c0d57dd16a9f19288"); + expect(md5(data)).toEqual("d7e682534f5118c62659afe5b13fe33e"); }); test("Clip the image", async () => { @@ -246,7 +247,7 @@ describe("SVG to image conversion", () => { width: 100, height: 100 }); - expect(md5(data)).toEqual("68c1e882efb0a3ce1791e5a6e6b80bd7"); + expect(md5(data)).toEqual("29f7cd260d1ceaa8aef7964e5f2f7ae0"); }); test("Wait for browser destruction", async (done) => { @@ -274,6 +275,42 @@ describe("SVG to image conversion", () => { done(); }, 1000); }); + + test("Propagates error when cannot connect", async (done) => { + const convert = svgToImg.connect({ browserWSEndpoint: "ws://localhost:12345" }) + try { + await convert.from("").toPng(); + done.fail(); + } catch (error) { + expect(error.message).toContain("ECONNREFUSED"); + done(); + } + }); + + test("Propagates error when cannot connect with subsequent attempts", async (done) => { + const convert = svgToImg.connect({ browserWSEndpoint: "ws://localhost:12345" }); + let errors = 0; + for (let i = 0; i < 10; i++) { + try { + await convert.from("").toPng(); + done.fail(); + } catch (error) { + expect(error.message).toContain("ECONNREFUSED"); + errors++; + } + } + expect(errors).toBe(10); + done(); + }); + + test("Special character encoding", async (done) => { + try { + await svgToImg.from("äöüÄÖÜçéèñ").toPng(); + done(); + } catch (error) { + done.fail(); + } + }); }); // Kill any remaining Chromium instances diff --git a/src/typings/index.ts b/src/typings/index.ts index 654cfd9..cab1d01 100644 --- a/src/typings/index.ts +++ b/src/typings/index.ts @@ -19,3 +19,9 @@ export interface IOptions { export interface IShorthandOptions extends IOptions { type?: never; } + +export interface IConnectOptions { + browserWSEndpoint?: string; + browserURL?: string; + ignoreHTTPSErrors?: boolean; +} diff --git a/tslint.json b/tslint.json index 4855b4d..c5cc6b1 100644 --- a/tslint.json +++ b/tslint.json @@ -14,7 +14,8 @@ "no-consecutive-blank-lines": true, "no-console": false, "object-literal-sort-keys": false, - "ordered-imports": false + "ordered-imports": false, + "max-classes-per-file": false }, "rulesDirectory": [] }