diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +bin/ diff --git a/.eslintrc.json b/.eslintrc.json index 3e5cf2d..0259af5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,5 +2,8 @@ "extends": "notninja/es8", "env": { "node": true + }, + "rules": { + "no-await-in-loop": "off" } } diff --git a/README.md b/README.md index a4e3356..97d4951 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A [Node.js](https://nodejs.org) module for converting SVG to PNG using headless [![Release](https://img.shields.io/npm/v/convert-svg-to-png.svg?style=flat-square)](https://www.npmjs.com/package/convert-svg-to-png) * [Install](#install) +* [CLI](#cli) * [API](#api) * [Bugs](#bugs) * [Contributors](#contributors) @@ -24,6 +25,28 @@ $ npm install --save convert-svg-to-png You'll need to have at least [Node.js](https://nodejs.org) 8 or newer. +If you want to use the command line interface you'll most likely want to install it globally so that you can run +`convert-svg-to-png` from anywhere: + +``` bash +$ npm install --global convert-svg-to-png +``` + +## CLI + + Usage: convert-svg-to-png [options] [files...] + + + Options: + + -V, --version output the version number + --no-color disables color output + -b, --base-url specify base URL to use for all relative URLs in SVG + -f, --filename specify name the for target PNG file when processing STDIN + --height specify height for PNG + --width specify width for PNG + -h, --help output usage information + ## API ### `convert(source[, options])` @@ -36,12 +59,14 @@ If the width and/or height cannot be derived from `source` then they must be pro This method attempts to derive the dimensions from `source` via any `width`/`height` attributes or its calculated `viewBox` attribute. +This method is resolved with the PNG buffer. + An error will occur if `source` does not contain an SVG element or no `width` and/or `height` options were provided and this information could not be derived from `source`. #### Options -| Option | Type | Default | Description | +| Option | Type | Default | Description | | ---------- | ------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `baseFile` | String | N/A | Path of file to be converted into a file URL to use for all relative URLs contained within SVG. Overrides `baseUrl` option. | | `baseUrl` | String | `"file:///path/to/cwd"` | Base URL to use for all relative URLs contained within SVG. Overridden by `baseUrl` option. | @@ -52,44 +77,77 @@ this information could not be derived from `source`. ``` javascript const { convert } = require('convert-svg-to-png'); -const fs = require('fs'); -const path = require('path'); -const util = require('util'); +const express = require('express'); -const readFile = util.promisify(fs.readFile); -const writeFile = util.promisify(fs.writeFile); +const app = express(); -async function convertSvgFile(filePath) { - const dirPath = path.dirname(filePath); - const input = await readFile(filePath); - const output = await convert(input, { baseFile: dirPath }); +app.post('/convert', async(req, res) => { + const png = await convert(req.body); - await writeFile(path.join(dirPath, `${path.basename(filePath, '.svg')}.png`), output); -} + res.set('Content-Type', 'image/png'); + res.send(png); +}); + +app.listen(3000); +``` + +### `convertFile(sourceFilePath[, options])` + +Converts the SVG file at the specified path into a PNG using the `options` provided and writes it to the the target +file. + +The target file is derived from `sourceFilePath` unless the `targetFilePath` option is specified. + +If the width and/or height cannot be derived from the source file then they must be provided via their corresponding +options. This method attempts to derive the dimensions from the source file via any `width`/`height` attributes or its +calculated `viewBox` attribute. + +This method is resolved with the path of the target (PNG) file for reference. + +An error will occur if the source file does not contain an SVG element, no `width` and/or `height` options were provided +and this information could not be derived from source file, or a problem arises while reading the source file or writing +the target file. + +#### Options + +Has the same options as the standard `convert` method but also supports the following additional options: + +| Option | Type | Default | Description | +| ---------------- | ------ | ------------------------------------------------------ | ------------------------------------------------------------- | +| `targetFilePath` | String | `sourceFilePath` with extension replaced with `".png"` | Path of the file to which the PNG output should be written to | + +#### Example + +``` javascript +const { convertFile } = require('convert-svg-to-png'); + +(async() => { + const sourceFilePath = '/path/to/my-image.svg'; + const targetFilePath = await convertFile(sourceFilePath); + + console.log(targetFilePath); + //=> "/path/to/my-image.png" +})(); ``` ### `createConverter()` Creates an instance of `Converter`. -It is important to note that, after the first time `Converter#convert` is called, a headless Chromium instance will -remain open until `Converter#destroy` is called. This is done automatically when using the main API -[convert](#convertsource-options) method, however, when using `Converter` directly, it is the responsibility of the -caller. Due to the fact that creating browser instances is expensive, this level of control allows callers to reuse a -browser for multiple conversions. It's not recommended to keep an instance around for too long, as it will use up -resources. +It is important to note that, after the first time either `Converter#convert` or `Converter#convertFile` are called, a +headless Chromium instance will remain open until `Converter#destroy` is called. This is done automatically when using +the main API convert methods, however, when using `Converter` directly, it is the responsibility of the caller. Due to +the fact that creating browser instances is expensive, this level of control allows callers to reuse a browser for +multiple conversions. It's not recommended to keep an instance around for too long, as it will use up resources. #### Example ``` javascript const { createConverter } = require('convert-svg-to-png'); const fs = require('fs'); -const path = require('path'); const util = require('util'); const readdir = util.promisify(fs.readdir); -const readFile = util.promisify(fs.readFile); -const writeFile = util.promisify(fs.writeFile); async function convertSvgFiles(dirPath) { const converter = createConverter(); @@ -98,15 +156,7 @@ async function convertSvgFiles(dirPath) { const filePaths = await readdir(dirPath); for (const filePath of filePaths) { - const extension = path.extname(filePath); - if (extension !== '.svg') { - continue; - } - - const input = await readFile(path.join(dirPath, filePath)); - const output = await converter.convert(input, { baseFile: dirPath }); - - await writeFile(path.join(dirPath, `${path.basename(filePath, extension)}.png`), output); + await converter.convertFile(filePath); } } finally { await converter.destroy(); @@ -123,7 +173,7 @@ The current version of this library. ``` javascript const { version } = require('convert-svg-to-png'); -version; +console.log(version); //=> "0.1.0" ``` diff --git a/bin/convert-svg-to-png b/bin/convert-svg-to-png new file mode 100755 index 0000000..b71a305 --- /dev/null +++ b/bin/convert-svg-to-png @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +/* + * Copyright (C) 2017 Alasdair Mercer, !ninja + * + * 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. + */ + +'use strict'; + +const CLI = require('../src/cli'); + +(async() => { + const cli = new CLI(); + + try { + await cli.parse(process.argv); + } catch (e) { + cli.error(`convert-svg-to-png failed: ${e.stack}`); + + process.exit(1); + } +})(); diff --git a/package-lock.json b/package-lock.json index bbeb528..1d303e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -206,30 +206,27 @@ } }, "chalk": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.1.0.tgz", - "integrity": "sha512-LUHGS/dge4ujbXMJrnihYMcL4AoOweGnw9Tp3kQuqy1Kx5c1qKjqvMJZ6nVJPMWJtKCTN72ZogH3oeSO9g9rXQ==", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", "requires": { "ansi-styles": "3.2.0", "escape-string-regexp": "1.0.5", - "supports-color": "4.4.0" + "supports-color": "4.5.0" }, "dependencies": { "ansi-styles": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", - "dev": true, "requires": { "color-convert": "1.9.0" } }, "supports-color": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", - "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", - "dev": true, + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", "requires": { "has-flag": "2.0.0" } @@ -273,7 +270,6 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.0.tgz", "integrity": "sha1-Gsz5fdc5uYO/mU1W/sj5WFNkG3o=", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -281,14 +277,12 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "commander": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "dev": true + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" }, "concat-map": { "version": "0.0.1", @@ -391,8 +385,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "eslint": { "version": "4.9.0", @@ -402,7 +395,7 @@ "requires": { "ajv": "5.2.3", "babel-code-frame": "6.26.0", - "chalk": "2.1.0", + "chalk": "2.3.0", "concat-stream": "1.6.0", "cross-spawn": "5.1.0", "debug": "3.1.0", @@ -641,6 +634,11 @@ "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", "dev": true }, + "get-stdin": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz", + "integrity": "sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g=" + }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", @@ -698,8 +696,7 @@ "has-flag": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", - "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", - "dev": true + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=" }, "he": { "version": "1.1.1", @@ -755,7 +752,7 @@ "dev": true, "requires": { "ansi-escapes": "3.0.0", - "chalk": "2.1.0", + "chalk": "2.3.0", "cli-cursor": "2.1.0", "cli-width": "2.2.0", "external-editor": "2.0.5", @@ -1384,7 +1381,7 @@ "requires": { "ajv": "5.2.3", "ajv-keywords": "2.1.0", - "chalk": "2.1.0", + "chalk": "2.3.0", "lodash": "4.17.4", "slice-ansi": "1.0.0", "string-width": "2.1.1" diff --git a/package.json b/package.json index a75b391..21da51f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,11 @@ "url": "https://github.com/NotNinja/convert-svg-to-png.git" }, "dependencies": { + "chalk": "^2.3.0", + "commander": "^2.11.0", "file-url": "^2.0.2", + "get-stdin": "^5.0.1", + "glob": "^7.1.2", "puppeteer": "^0.12.0", "tmp": "0.0.33" }, @@ -35,8 +39,12 @@ "eslint": "^4.9.0", "eslint-config-notninja": "^0.2.3", "mocha": "^4.0.1", + "rimraf": "^2.6.2", "sinon": "^4.0.1" }, + "bin": { + "convert-svg-to-png": "bin/convert-svg-to-png" + }, "main": "src/index.js", "scripts": { "pretest": "eslint \"src/**/*.js\" \"test/**/*.js\"", diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..635876a --- /dev/null +++ b/src/cli.js @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2017 Alasdair Mercer, !ninja + * + * 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. + */ + +'use strict'; + +const chalk = require('chalk'); +const { Command } = require('commander'); +const { EOL } = require('os'); +const fs = require('fs'); +const getStdin = require('get-stdin').buffer; +const glob = require('glob'); +const path = require('path'); +const util = require('util'); + +const { createConverter } = require('./'); +const pkg = require('../package.json'); + +const findFiles = util.promisify(glob); +const writeFile = util.promisify(fs.writeFile); + +const _baseDir = Symbol('baseDir'); +const _command = Symbol('command'); +const _convertFiles = Symbol('convertFiles'); +const _convertSource = Symbol('convertSource'); +const _errorStream = Symbol('errorStream'); +const _inputStream = Symbol('inputStream'); +const _outputStream = Symbol('outputStream'); + +/** + * The command-line interface for convert-svg-to-png. + * + * While technically part of the API, this is not expected to be used outside of this package as it's only intended use + * is by bin/convert-svg-to-png. + * + * @public + */ +class CLI { + + /** + * Creates an instance of {@link CLI} using the options provided. + * + * options is primarily intended for testing purposes and it's not expected to be supplied in any + * real-world scenario. + * + * @param {CLI~Options} [options] - the options to be used + * @public + */ + constructor(options = {}) { + this[_baseDir] = options.baseDir || process.cwd(); + this[_errorStream] = options.errorStream || process.stderr; + this[_inputStream] = options.inputStream || process.stdin; + this[_outputStream] = options.outputStream || process.stdout; + this[_command] = new Command() + .version(pkg.version) + .usage('[options] [files...]') + .option('--no-color', 'disables color output') + .option('-b, --base-url ', 'specify base URL to use for all relative URLs in SVG') + .option('-f, --filename ', 'specify name the for target PNG file when processing STDIN') + .option('--height ', 'specify height for PNG') + .option('--width ', 'specify width for PNG'); + } + + /** + * Writes the specified message to the error stream for this {@link CLI}. + * + * @param {string} message - the message to be written to the error stream + * @return {void} + * @public + */ + error(message) { + this[_errorStream].write(`${message}${EOL}`); + } + + /** + * Writes the specified message to the output stream for this {@link CLI}. + * + * @param {string} message - the message to be written to the output stream + * @return {void} + * @public + */ + output(message) { + this[_outputStream].write(`${message}${EOL}`); + } + + /** + * Parses the command-line (process) arguments provided and performs the necessary actions based on the parsed input. + * + * An error will occur if any problem arises. + * + * @param {string[]} [args] - the arguments to be parsed + * @return {Promise.} A Promise for any asynchronous file traversal and/or buffer reading + * and writing. + * @public + */ + async parse(args = []) { + const command = this[_command].parse(args); + const converter = createConverter(); + const options = { + baseUrl: command.baseUrl, + converter, + filePath: command.filename ? path.resolve(this.baseDir, command.filename) : null, + height: command.height, + width: command.width + }; + + try { + if (command.args.length) { + const filePaths = []; + + for (const arg of command.args) { + const files = await findFiles(arg, { + absolute: true, + cwd: this.baseDir, + nodir: true + }); + + filePaths.push(...files); + } + + await this[_convertFiles](filePaths, options); + } else { + const source = await getStdin(); + + await this[_convertSource](source, options); + } + } finally { + await converter.destroy(); + } + } + + async [_convertFiles](filePaths, options) { + for (const sourceFilePath of filePaths) { + const targetFilePath = await options.converter.convertFile(sourceFilePath, { + baseUrl: options.baseUrl, + height: options.height, + width: options.width + }); + + this.output(`Converted SVG file to PNG file: ${chalk.blue(sourceFilePath)} -> ${chalk.blue(targetFilePath)}`); + } + + this.output(chalk.green('Done!')); + } + + async [_convertSource](source, options) { + const target = await options.converter.convert(source, { + baseFile: !options.baseUrl ? this.baseDir : null, + baseUrl: options.baseUrl, + height: options.height, + width: options.width + }); + + if (options.filePath) { + await writeFile(options.filePath, target); + + this.output(`Converted SVG input to PNG file: ${chalk.blue(options.filePath)}`); + this.output(chalk.green('Done!')); + } else { + this[_outputStream].write(target); + } + } + + /** + * Returns the base directory for this {@link CLI}. + * + * @return {string} The base directory. + * @public + */ + get baseDir() { + return this[_baseDir]; + } + +} + +module.exports = CLI; + +/** + * The options that can be passed to the {@link CLI} constructor. + * + * @typedef {Object} CLI~Options + * @property {string} [baseDir=process.cwd()] - The base directory to be used. + * @property {Writable} [errorStream=process.stderr] - The stream for error messages to be written to. + * @property {Readable} [inputStream=process.stdin] - The stream for input to be read from. + * @property {Writable} [outputStream=process.stdout] - The stream for output messages to be written to. + */ diff --git a/src/converter.js b/src/converter.js index 90b3b64..561ccec 100644 --- a/src/converter.js +++ b/src/converter.js @@ -26,13 +26,16 @@ const fileUrl = require('file-url'); const fs = require('fs'); +const path = require('path'); const puppeteer = require('puppeteer'); const tmp = require('tmp'); const util = require('util'); +const readFile = util.promisify(fs.readFile); const writeFile = util.promisify(fs.writeFile); const _browser = Symbol('browser'); +const _convert = Symbol('convert'); const _destroyed = Symbol('destroyed'); const _getDimensions = Symbol('getDimensions'); const _getPage = Symbol('getPage'); @@ -41,17 +44,18 @@ const _page = Symbol('page'); const _parseOptions = Symbol('parseOptions'); const _setDimensions = Symbol('setDimensions'); const _tempFile = Symbol('tempFile'); +const _validate = Symbol('validate'); /** * Converts SVG to PNG using a headless Chromium instance. * - * It is important to note that, after the first time {@link Converter#convert} is called, a headless Chromium instance - * will remain open until {@link Converter#destroy} is called. This is done automatically when using the main API - * convert method, however, when using {@link Converter} directly, it is the responsibility of the caller. - * Due to the fact that creating browser instances is expensive, this level of control allows callers to reuse a browser - * for multiple conversions. For example; one could create a {@link Converter} and use it to convert a collection of - * SVG files to PNG files and then destroy it afterwards. It's not recommended to keep an instance around for too long, - * as it will use up resources. + * It is important to note that, after the first time either {@link Converter#convert} or{@link Converter#convertFile} + * are called, a headless Chromium instance will remain open until {@link Converter#destroy} is called. This is done + * automatically when using the main API convert methods, however, when using {@link Converter} directly, it is the + * responsibility of the caller. Due to the fact that creating browser instances is expensive, this level of control + * allows callers to reuse a browser for multiple conversions. For example; one could create a {@link Converter} and use + * it to convert a collection of SVG files to PNG files and then destroy it afterwards. It's not recommended to keep an + * instance around for too long, as it will use up resources. * * Due constraints within Chromium, the SVG source is first written to a temporary HTML file and then navigated to. This * is because the default page for Chromium is using the chrome protocol so cannot load externally @@ -65,15 +69,22 @@ const _tempFile = Symbol('tempFile'); */ class Converter { - static [_parseOptions](options) { + static [_parseOptions](options, sourceFilePath) { options = Object.assign({}, options); + if (!options.targetFilePath && sourceFilePath) { + const targetDirPath = path.dirname(sourceFilePath); + const targetFileName = `${path.basename(sourceFilePath, path.extname(sourceFilePath))}.png`; + + options.targetFilePath = path.join(targetDirPath, targetFileName); + } + if (typeof options.baseFile === 'string') { options.baseUrl = fileUrl(options.baseFile); delete options.baseFile; } if (!options.baseUrl) { - options.baseUrl = fileUrl(process.cwd()); + options.baseUrl = fileUrl(sourceFilePath ? path.resolve(sourceFilePath) : process.cwd()); } if (typeof options.height === 'string') { @@ -104,53 +115,60 @@ class Converter { * corresponding options. This method attempts to derive the dimensions from source via any * width/height attributes or its calculated viewBox attribute. * + * This method is resolved with the PNG buffer. + * * An error will occur if this {@link Converter} has been destroyed, source does not contain an SVG * element, or no width and/or height options were provided and this information could not * be derived from source. * * @param {Buffer|string} source - the SVG source to be converted to a PNG * @param {Converter~ConvertOptions} [options] - the options to be used - * @return {Promise.} A Promise for the asynchronous temporary file creation/writing and - * browser interactions that is resolved with the PNG buffer. + * @return {Promise.} A Promise that is resolved with the PNG buffer. * @public */ async convert(source, options) { - if (this[_destroyed]) { - throw new Error('Converter has been destroyed. A new Converter must be created'); - } + this[_validate](); - source = Buffer.isBuffer(source) ? source.toString('utf8') : source; options = Converter[_parseOptions](options); - const start = source.indexOf('`; - if (start >= 0) { - html += source.substring(start); - } else { - throw new Error('SVG element open tag not found in source. Check the SVG source'); - } - - const page = await this[_getPage](html); + return target; + } - await this[_setDimensions](page, options); + /** + * Converts the SVG file at the specified path into a PNG using the options provided and writes it to the + * the target file. + * + * The target file is derived from sourceFilePath unless the targetFilePath option is + * specified. + * + * If the width and/or height cannot be derived from the source file then they must be provided via their + * corresponding options. This method attempts to derive the dimensions from the source file via any + * width/height attributes or its calculated viewBox attribute. + * + * This method is resolved with the path of the target (PNG) file for reference. + * + * An error will occur if this {@link Converter} has been destroyed, the source file does not contain an SVG element, + * no width and/or height options were provided and this information could not be derived + * from source file, or a problem arises while reading the source file or writing the target file. + * + * @param {string} sourceFilePath - the path of the SVG file to be converted to a PNG file + * @param {Converter~ConvertFileOptions} [options] - the options to be used + * @return {Promise.} A Promise that is resolved with the target file path. + * @public + */ + async convertFile(sourceFilePath, options) { + this[_validate](); - const dimensions = await this[_getDimensions](page); - if (!dimensions) { - throw new Error('Unable to derive width and height from SVG. Consider specifying corresponding options'); - } + options = Converter[_parseOptions](options, sourceFilePath); - await page.setViewport({ - width: Math.round(dimensions.width), - height: Math.round(dimensions.height) - }); + const source = await readFile(sourceFilePath); + const target = await this[_convert](source, options); - const target = await page.screenshot({ - clip: Object.assign({ x: 0, y: 0 }, dimensions), - omitBackground: true - }); + await writeFile(options.targetFilePath, target); - return target; + return options.targetFilePath; } /** @@ -184,6 +202,40 @@ class Converter { } } + async [_convert](source, options) { + source = Buffer.isBuffer(source) ? source.toString('utf8') : source; + + const start = source.indexOf('`; + if (start >= 0) { + html += source.substring(start); + } else { + throw new Error('SVG element open tag not found in source. Check the SVG source'); + } + + const page = await this[_getPage](html); + + await this[_setDimensions](page, options); + + const dimensions = await this[_getDimensions](page); + if (!dimensions) { + throw new Error('Unable to derive width and height from SVG. Consider specifying corresponding options'); + } + + await page.setViewport({ + width: Math.round(dimensions.width), + height: Math.round(dimensions.height) + }); + + const target = await page.screenshot({ + clip: Object.assign({ x: 0, y: 0 }, dimensions), + omitBackground: true + }); + + return target; + } + [_getDimensions](page) { return page.evaluate(() => { const el = document.querySelector('svg'); @@ -279,6 +331,12 @@ class Converter { }, dimensions); } + [_validate]() { + if (this[_destroyed]) { + throw new Error('Converter has been destroyed. A new Converter must be created'); + } + } + /** * Returns whether this {@link Converter} has been destroyed. * @@ -294,6 +352,14 @@ class Converter { module.exports = Converter; +/** + * The options that can be passed to {@link Converter#convertFile}. + * + * @typedef {Converter~ConvertOptions} Converter~ConvertFileOptions + * @property {string} [targetFilePath] - The path of the file to which the PNG output should be written to. By default, + * this will be derived from the source file path. + */ + /** * The options that can be passed to {@link Converter#convert}. * diff --git a/src/index.js b/src/index.js index b88c99f..5885f3c 100644 --- a/src/index.js +++ b/src/index.js @@ -35,13 +35,14 @@ const { version } = require('../package.json'); * corresponding options. This method attempts to derive the dimensions from source via any * width/height attributes or its calculated viewBox attribute. * + * This method is resolved with the PNG buffer. + * * An error will occur if source does not contain an SVG element or no width and/or * height options were provided and this information could not be derived from source. * * @param {Buffer|string} source - the SVG source to be converted to a PNG * @param {Converter~ConvertOptions} [options] - the options to be used - * @return {Promise.} A Promise for the asynchronous conversion that is resolved with the - * PNG buffer. + * @return {Promise.} A Promise that is resolved with the PNG buffer. * @public */ async function convert(source, options) { @@ -58,16 +59,52 @@ async function convert(source, options) { return target; } +/** + * Converts the SVG file at the specified path into a PNG using the options provided and writes it to the + * the target file. + * + * The target file is derived from sourceFilePath unless the targetFilePath option is + * specified. + * + * If the width and/or height cannot be derived from the source file then they must be provided via their + * corresponding options. This method attempts to derive the dimensions from the source file via any + * width/height attributes or its calculated viewBox attribute. + * + * This method is resolved with the path of the target (PNG) file for reference. + * + * An error will occur if the source file does not contain an SVG element, no width and/or + * height options were provided and this information could not be derived from source file, or a problem + * arises while reading the source file or writing the target file. + * + * @param {string} sourceFilePath - the path of the SVG file to be converted to a PNG file + * @param {Converter~ConvertFileOptions} [options] - the options to be used + * @return {Promise.} A Promise that is resolved with the target file path. + * @public + */ +async function convertFile(sourceFilePath, options) { + // Reference method via module.exports to allow unit test to spy on converter + const converter = module.exports.createConverter(); + let targetFilePath; + + try { + targetFilePath = await converter.convertFile(sourceFilePath, options); + } finally { + await converter.destroy(); + } + + return targetFilePath; +} + /** * Creates an instance of {@link Converter}. * - * It is important to note that, after the first time {@link Converter#convert} is called, a headless Chromium instance - * will remain open until {@link Converter#destroy} is called. This is done automatically when using the main API - * convert method within this module, however, when using {@link Converter} directly, it is the - * responsibility of the caller. Due to the fact that creating browser instances is expensive, this level of control - * allows callers to reuse a browser for multiple conversions. For example; one could create a {@link Converter} and use - * it to convert a collection of SVG files to PNG files and then destroy it afterwards. It's not recommended to keep an - * instance around for too long, as it will use up resources. + * It is important to note that, after the first time either {@link Converter#convert} or {@link Converter#convertFile} + * are called, a headless Chromium instance will remain open until {@link Converter#destroy} is called. This is done + * automatically when using the main API convert methods within this module, however, when using {@link Converter} + * directly, it is the responsibility of the caller. Due to the fact that creating browser instances is expensive, this + * level of control allows callers to reuse a browser for multiple conversions. For example; one could create a + * {@link Converter} and use it to convert a collection of SVG files to PNG files and then destroy it afterwards. It's + * not recommended to keep an instance around for too long, as it will use up resources. * * @return {Converter} A newly created {@link Converter} instance. * @public @@ -76,4 +113,9 @@ function createConverter() { return new Converter(); } -module.exports = { convert, createConverter, version }; +module.exports = { + convert, + convertFile, + createConverter, + version +}; diff --git a/test/converter.spec.js b/test/converter.spec.js index 471849b..61a7b23 100644 --- a/test/converter.spec.js +++ b/test/converter.spec.js @@ -34,7 +34,7 @@ const { expect } = chai; const readFile = util.promisify(fs.readFile); const Converter = require('../src/converter'); -const { createTests } = require('./helper'); +const { createConvertFileTests, createConvertTests } = require('./helper'); describe('Converter', () => { let converter; @@ -48,7 +48,7 @@ describe('Converter', () => { await converter.destroy(); }); - createTests(() => converter.convert.bind(converter), 200); + createConvertTests(() => converter.convert.bind(converter), 200); context('when source is a string', function() { /* eslint-disable no-invalid-this */ @@ -84,6 +84,27 @@ describe('Converter', () => { }); }); + describe('#convertFile', () => { + before(() => { + converter = new Converter(); + }); + + after(async() => { + await converter.destroy(); + }); + + createConvertFileTests(() => converter.convertFile.bind(converter), 250); + + context('when destroyed', () => { + it('should thrown an error', async() => { + await converter.destroy(); + + await expect(converter.convertFile('')).to.eventually.be.rejectedWith(Error, + 'Converter has been destroyed. A new Converter must be created'); + }); + }); + }); + describe('#destroy', () => { beforeEach(() => { converter = new Converter(); diff --git a/test/helper.js b/test/helper.js index 14dbdb1..35df7c6 100644 --- a/test/helper.js +++ b/test/helper.js @@ -27,74 +27,94 @@ const chaiAsPromised = require('chai-as-promised'); const fileUrl = require('file-url'); const fs = require('fs'); const path = require('path'); +const rimraf = require('rimraf'); const util = require('util'); chai.use(chaiAsPromised); const { expect } = chai; -const mkdir = util.promisify(fs.mkdir); +const makeDirectory = util.promisify(fs.mkdir); const readFile = util.promisify(fs.readFile); +const removeFile = util.promisify(rimraf); const writeFile = util.promisify(fs.writeFile); const { convert } = require('../src/index'); const tests = require('./tests.json'); /** - * Generates files within the test/fixtures/actual/ directory for each test within - * test/tests.json. - * - * This method can be extremely useful for generating expected files for new fixtures, updating existing ones after - * subtle changes/improvements within Chromium, and primarily to debug tests failing due to missmatched buffers since a - * visual comparison is more likely to help than comparing bytes. - * - * Error tests are ignored as they have no expected output. + * Describes each test within test/tests.json for the convertFile method. * - * An error will occur if any problem arises while trying to read/write any files or convert the SVG into a PNG. - * - * @return {Promise.} A Promise for the asynchronous file reading and writing as well as - * SVG-PNG conversion that is resolved once all files have been generated. + * @param {Function} methodSupplier - a function that returns the convertFile method to be tested + * @param {number} slow - the number of milliseconds to be considered "slow" + * @return {void} * @public */ -async function createFixtures() { - /* eslint-disable no-await-in-loop */ - let index = -1; - - for (const test of tests) { - index++; - - if (test.error) { - continue; - } - - const sourceFilePath = path.resolve(__dirname, 'fixtures', 'source', test.file); - const source = await readFile(sourceFilePath); - const options = parseOptions(test, sourceFilePath); - - const actualFilePath = path.resolve(__dirname, 'fixtures', 'actual', `${index}.png`); - const actual = await convert(source, options); - +function createConvertFileTests(methodSupplier, slow) { + before(async() => { try { - await mkdir(path.dirname(actualFilePath)); + await makeDirectory(path.resolve(__dirname, 'fixtures', 'actual')); } catch (e) { if (e.code !== 'EEXIST') { throw e; } } + }); - await writeFile(actualFilePath, actual); - } - /* eslint-enable no-await-in-loop */ + after(async() => { + await removeFile(path.resolve(__dirname, 'fixtures', 'actual'), { glob: false }); + }); + + tests.forEach((test, index) => { + context(`(test:${index}) ${test.name}`, function() { + /* eslint-disable no-invalid-this */ + this.slow(slow); + /* eslint-enable no-invalid-this */ + + const message = test.error ? 'should throw an error' : test.message; + + let targetFilePath; + let sourceFilePath; + let options; + let expectedFilePath; + let expected; + + before(async() => { + targetFilePath = path.resolve(__dirname, 'fixtures', 'actual', `${index}.png`); + sourceFilePath = path.resolve(__dirname, 'fixtures', 'source', test.file); + options = parseOptions(test, sourceFilePath, targetFilePath); + + if (!test.error) { + expectedFilePath = path.resolve(__dirname, 'fixtures', 'expected', `${index}.png`); + expected = await readFile(expectedFilePath); + } + }); + + it(message, async() => { + const method = methodSupplier(); + + if (test.error) { + await expect(method(sourceFilePath, options)).to.eventually.be.rejectedWith(Error, test.error); + } else { + const actualFilePath = await method(sourceFilePath, options); + const actual = await readFile(targetFilePath); + + expect(actualFilePath).to.equal(targetFilePath, 'Must match target file path'); + expect(actual).to.deep.equal(expected, 'Must match PNG buffer'); + } + }); + }); + }); } /** - * Describes each test within test/tests.json. + * Describes each test within test/tests.json for the convert method. * - * @param {Function} methodSupplier - a function that returns the convert method to be tested + * @param {Function} methodSupplier - a function that returns the convert method to be tested * @param {number} slow - the number of milliseconds to be considered "slow" * @return {void} * @public */ -function createTests(methodSupplier, slow) { +function createConvertTests(methodSupplier, slow) { tests.forEach((test, index) => { context(`(test:${index}) ${test.name}`, function() { /* eslint-disable no-invalid-this */ @@ -136,25 +156,79 @@ function createTests(methodSupplier, slow) { } /** + * Generates files within the test/fixtures/actual/ directory for each test within + * test/tests.json. + * + * This method can be extremely useful for generating expected files for new fixtures, updating existing ones after + * subtle changes/improvements within Chromium, and primarily to debug tests failing due to missmatched buffers since a + * visual comparison is more likely to help than comparing bytes. + * + * Error tests are ignored as they have no expected output. + * + * An error will occur if any problem arises while trying to read/write any files or convert the SVG into a PNG. + * + * @return {Promise.} A Promise for the asynchronous file reading and writing as well as + * SVG-PNG conversion that is resolved once all files have been generated. + * @public + */ +async function createFixtures() { + let index = -1; + + for (const test of tests) { + index++; + + if (test.error) { + continue; + } + + const sourceFilePath = path.resolve(__dirname, 'fixtures', 'source', test.file); + const source = await readFile(sourceFilePath); + const options = parseOptions(test, sourceFilePath); + + const actualFilePath = path.resolve(__dirname, 'fixtures', 'actual', `${index}.png`); + const actual = await convert(source, options); + + try { + await makeDirectory(path.dirname(actualFilePath)); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + } + + await writeFile(actualFilePath, actual); + } +} + +/** + * TODO: Document * Parses the options for the specified test using the filePath provided, where appropriate. * * @param {Object} test - the test whose options are to be parsed - * @param {string} filePath - the path of the file to be used to populate the baseFile and/or + * @param {string} sourceFilePath - the path of the file to be used to populate the baseFile and/or * baseUrl options, if needed + * @param {string} [targetFilePath] - * @return {Object} The parsed options for test. * @private */ -function parseOptions(test, filePath) { +function parseOptions(test, sourceFilePath, targetFilePath) { const options = Object.assign({}, test.options); + if (targetFilePath) { + options.targetFilePath = targetFilePath; + } if (test.includeFile) { - options.baseFile = filePath; + options.baseFile = sourceFilePath; } if (test.includeUrl) { - options.baseUrl = fileUrl(filePath); + options.baseUrl = fileUrl(sourceFilePath); } return options; } -module.exports = { createFixtures, createTests }; +module.exports = { + createConvertFileTests, + createConvertTests, + createFixtures +}; diff --git a/test/index.spec.js b/test/index.spec.js index 51affa2..7652095 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -26,7 +26,7 @@ const { expect } = require('chai'); const sinon = require('sinon'); const Converter = require('../src/converter'); -const { createTests } = require('./helper'); +const { createConvertFileTests, createConvertTests } = require('./helper'); const index = require('../src/index'); const pkg = require('../package.json'); @@ -48,7 +48,27 @@ describe('index', () => { index.createConverter.restore(); }); - createTests(() => index.convert, 350); + createConvertTests(() => index.convert, 350); + }); + + describe('.convertFile', () => { + let converter; + + beforeEach(() => { + converter = new Converter(); + + sinon.spy(converter, 'destroy'); + sinon.stub(index, 'createConverter').returns(converter); + }); + + afterEach(() => { + expect(index.createConverter.callCount).to.equal(1, 'createConverter must be called'); + expect(converter.destroy.callCount).to.equal(1, 'Converter#destroy must be called'); + + index.createConverter.restore(); + }); + + createConvertFileTests(() => index.convertFile, 400); }); describe('.createConverter', () => {