UNPKG

15.4 kBJavaScriptView Raw
1/*
2 * Copyright (C) 2017 Alasdair Mercer, !ninja
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a copy
5 * of this software and associated documentation files (the "Software"), to deal
6 * in the Software without restriction, including without limitation the rights
7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 * copies of the Software, and to permit persons to whom the Software is
9 * furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in all
12 * copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 * SOFTWARE.
21 */
22
23'use strict';
24
25/* global document: false */
26
27const fileUrl = require('file-url');
28const fs = require('fs');
29const path = require('path');
30const puppeteer = require('puppeteer');
31const tmp = require('tmp');
32const util = require('util');
33
34const readFile = util.promisify(fs.readFile);
35const writeFile = util.promisify(fs.writeFile);
36
37const _browser = Symbol('browser');
38const _convert = Symbol('convert');
39const _destroyed = Symbol('destroyed');
40const _getDimensions = Symbol('getDimensions');
41const _getPage = Symbol('getPage');
42const _getTempFile = Symbol('getTempFile');
43const _options = Symbol('options');
44const _page = Symbol('page');
45const _parseOptions = Symbol('parseOptions');
46const _provider = Symbol('provider');
47const _setDimensions = Symbol('setDimensions');
48const _tempFile = Symbol('tempFile');
49const _validate = Symbol('validate');
50
51/**
52 * Converts SVG to another format using a headless Chromium instance.
53 *
54 * It is important to note that, after the first time either {@link Converter#convert} or{@link Converter#convertFile}
55 * are called, a headless Chromium instance will remain open until {@link Converter#destroy} is called. This is done
56 * automatically when using the {@link API} convert methods, however, when using {@link Converter} directly, it is the
57 * responsibility of the caller. Due to the fact that creating browser instances is expensive, this level of control
58 * allows callers to reuse a browser for multiple conversions. For example; one could create a {@link Converter} and use
59 * it to convert a collection of SVG files to files in another format and then destroy it afterwards. It's not
60 * recommended to keep an instance around for too long, as it will use up resources.
61 *
62 * Due constraints within Chromium, the SVG input is first written to a temporary HTML file and then navigated to. This
63 * is because the default page for Chromium is using the <code>chrome</code> protocol so cannot load externally
64 * referenced files (e.g. that use the <code>file</code> protocol). This temporary file is reused for the lifespan of
65 * each {@link Converter} instance and will be deleted when it is destroyed.
66 *
67 * It's also the responsibility of the caller to ensure that all {@link Converter} instances are destroyed before the
68 * process exits. This is why a short-lived {@link Converter} instance combined with a try/finally block is ideal.
69 *
70 * @public
71 */
72class Converter {
73
74 /**
75 * Creates an instance of {@link Converter} using the specified <code>provider</code> and the <code>options</code>
76 * provided.
77 *
78 * @param {Provider} provider - the {@link Provider} to be used
79 * @param {Converter~Options} [options] - the options to be used
80 * @public
81 */
82 constructor(provider, options) {
83 this[_provider] = provider;
84 this[_options] = Object.assign({}, options);
85 this[_destroyed] = false;
86 }
87
88 /**
89 * Converts the specified <code>input</code> SVG into another format using the <code>options</code> provided.
90 *
91 * <code>input</code> can either be a SVG buffer or string.
92 *
93 * If the width and/or height cannot be derived from <code>input</code> then they must be provided via their
94 * corresponding options. This method attempts to derive the dimensions from <code>input</code> via any
95 * <code>width</code>/<code>height</code> attributes or its calculated <code>viewBox</code> attribute.
96 *
97 * This method is resolved with the converted output buffer.
98 *
99 * An error will occur if this {@link Converter} has been destroyed, both the <code>baseFile</code> and
100 * <code>baseUrl</code> options have been provided, <code>input</code> does not contain an SVG element, or no
101 * <code>width</code> and/or <code>height</code> options were provided and this information could not be derived from
102 * <code>input</code>.
103 *
104 * @param {Buffer|string} input - the SVG input to be converted to another format
105 * @param {Converter~ConvertOptions} [options] - the options to be used
106 * @return {Promise.<Buffer, Error>} A <code>Promise</code> that is resolved with the converted output buffer.
107 * @public
108 */
109 async convert(input, options) {
110 this[_validate]();
111
112 options = this[_parseOptions](options);
113
114 const output = await this[_convert](input, options);
115
116 return output;
117 }
118
119 /**
120 * Converts the SVG file at the specified path into another format using the <code>options</code> provided and writes
121 * it to the output file.
122 *
123 * The output file is derived from <code>inputFilePath</code> unless the <code>outputFilePath</code> option is
124 * specified.
125 *
126 * If the width and/or height cannot be derived from the input file then they must be provided via their corresponding
127 * options. This method attempts to derive the dimensions from the input file via any
128 * <code>width</code>/<code>height</code> attributes or its calculated <code>viewBox</code> attribute.
129 *
130 * This method is resolved with the path of the converted output file for reference.
131 *
132 * An error will occur if this {@link Converter} has been destroyed, both the <code>baseFile</code> and
133 * <code>baseUrl</code> options have been provided, the input file does not contain an SVG element, no
134 * <code>width</code> and/or <code>height</code> options were provided and this information could not be derived from
135 * input file, or a problem arises while reading the input file or writing the output file.
136 *
137 * @param {string} inputFilePath - the path of the SVG file to be converted to another file format
138 * @param {Converter~ConvertFileOptions} [options] - the options to be used
139 * @return {Promise.<string, Error>} A <code>Promise</code> that is resolved with the output file path.
140 * @public
141 */
142 async convertFile(inputFilePath, options) {
143 this[_validate]();
144
145 options = this[_parseOptions](options, inputFilePath);
146
147 const input = await readFile(inputFilePath);
148 const output = await this[_convert](input, options);
149
150 await writeFile(options.outputFilePath, output);
151
152 return options.outputFilePath;
153 }
154
155 /**
156 * Destroys this {@link Converter}.
157 *
158 * This will close any headless Chromium browser that has been opend by this {@link Converter} as well as deleting any
159 * temporary file that it may have created.
160 *
161 * Once destroyed, this {@link Converter} should be discarded and a new one created, if needed.
162 *
163 * An error will occur if any problem arises while closing the browser, where applicable.
164 *
165 * @return {Promise.<void, Error>} A <code>Promise</code> that is resolved once this {@link Converter} has been
166 * destroyed.
167 * @public
168 */
169 async destroy() {
170 if (this[_destroyed]) {
171 return;
172 }
173
174 this[_destroyed] = true;
175
176 if (this[_tempFile]) {
177 this[_tempFile].cleanup();
178 delete this[_tempFile];
179 }
180 if (this[_browser]) {
181 await this[_browser].close();
182 delete this[_browser];
183 delete this[_page];
184 }
185 }
186
187 async [_convert](input, options) {
188 input = Buffer.isBuffer(input) ? input.toString('utf8') : input;
189
190 const { provider } = this;
191 const start = input.indexOf('<svg');
192
193 let html = `<!DOCTYPE html>
194<base href="${options.baseUrl}">
195<style>
196* { margin: 0; padding: 0; }
197html { background-color: ${provider.getBackgroundColor(options)}; }
198</style>`;
199 if (start >= 0) {
200 html += input.substring(start);
201 } else {
202 throw new Error('SVG element open tag not found in input. Check the SVG input');
203 }
204
205 const page = await this[_getPage](html);
206
207 await this[_setDimensions](page, options);
208
209 const dimensions = await this[_getDimensions](page);
210 if (!dimensions) {
211 throw new Error('Unable to derive width and height from SVG. Consider specifying corresponding options');
212 }
213
214 if (options.scale !== 1) {
215 dimensions.height *= options.scale;
216 dimensions.width *= options.scale;
217
218 await this[_setDimensions](page, dimensions);
219 }
220
221 await page.setViewport({
222 height: Math.round(dimensions.height),
223 width: Math.round(dimensions.width)
224 });
225
226 const output = await page.screenshot(Object.assign({
227 type: provider.getType(),
228 clip: Object.assign({ x: 0, y: 0 }, dimensions)
229 }, provider.getScreenshotOptions(options)));
230
231 return output;
232 }
233
234 [_getDimensions](page) {
235 return page.evaluate(() => {
236 const el = document.querySelector('svg');
237 if (!el) {
238 return null;
239 }
240
241 const widthIsPercent = (el.getAttribute('width') || '').endsWith('%');
242 const heightIsPercent = (el.getAttribute('height') || '').endsWith('%');
243 const width = !widthIsPercent && parseFloat(el.getAttribute('width'));
244 const height = !heightIsPercent && parseFloat(el.getAttribute('height'));
245
246 if (width && height) {
247 return { width, height };
248 }
249
250 const viewBoxWidth = el.viewBox.animVal.width;
251 const viewBoxHeight = el.viewBox.animVal.height;
252
253 if (width && viewBoxHeight) {
254 return {
255 width,
256 height: width * viewBoxHeight / viewBoxWidth
257 };
258 }
259
260 if (height && viewBoxWidth) {
261 return {
262 width: height * viewBoxWidth / viewBoxHeight,
263 height
264 };
265 }
266
267 return null;
268 });
269 }
270
271 async [_getPage](html) {
272 if (!this[_browser]) {
273 this[_browser] = await puppeteer.launch(this[_options].puppeteer);
274 this[_page] = await this[_browser].newPage();
275 }
276
277 const tempFile = await this[_getTempFile]();
278
279 await writeFile(tempFile.path, html);
280
281 await this[_page].goto(fileUrl(tempFile.path));
282
283 return this[_page];
284 }
285
286 [_getTempFile]() {
287 if (this[_tempFile]) {
288 return Promise.resolve(this[_tempFile]);
289 }
290
291 return new Promise((resolve, reject) => {
292 tmp.file({ prefix: 'convert-svg-', postfix: '.html' }, (error, filePath, fd, cleanup) => {
293 if (error) {
294 reject(error);
295 } else {
296 this[_tempFile] = { path: filePath, cleanup };
297
298 resolve(this[_tempFile]);
299 }
300 });
301 });
302 }
303
304 [_parseOptions](options, inputFilePath) {
305 options = Object.assign({}, options);
306
307 const { provider } = this;
308
309 if (!options.outputFilePath && inputFilePath) {
310 const extension = `.${provider.getExtension()}`;
311 const outputDirPath = path.dirname(inputFilePath);
312 const outputFileName = `${path.basename(inputFilePath, path.extname(inputFilePath))}${extension}`;
313
314 options.outputFilePath = path.join(outputDirPath, outputFileName);
315 }
316
317 if (options.baseFile != null && options.baseUrl != null) {
318 throw new Error('Both baseFile and baseUrl options specified. Use only one');
319 }
320 if (typeof options.baseFile === 'string') {
321 options.baseUrl = fileUrl(options.baseFile);
322 delete options.baseFile;
323 }
324 if (!options.baseUrl) {
325 options.baseUrl = fileUrl(inputFilePath ? path.resolve(inputFilePath) : process.cwd());
326 }
327
328 if (typeof options.height === 'string') {
329 options.height = parseInt(options.height, 10);
330 }
331 if (options.scale == null) {
332 options.scale = 1;
333 }
334 if (typeof options.width === 'string') {
335 options.width = parseInt(options.width, 10);
336 }
337
338 provider.parseAPIOptions(options, inputFilePath);
339
340 return options;
341 }
342
343 async [_setDimensions](page, dimensions) {
344 if (typeof dimensions.width !== 'number' && typeof dimensions.height !== 'number') {
345 return;
346 }
347
348 await page.evaluate(({ width, height }) => {
349 const el = document.querySelector('svg');
350 if (!el) {
351 return;
352 }
353
354 if (typeof width === 'number') {
355 el.setAttribute('width', `${width}px`);
356 } else {
357 el.removeAttribute('width');
358 }
359
360 if (typeof height === 'number') {
361 el.setAttribute('height', `${height}px`);
362 } else {
363 el.removeAttribute('height');
364 }
365 }, dimensions);
366 }
367
368 [_validate]() {
369 if (this[_destroyed]) {
370 throw new Error('Converter has been destroyed. A new Converter must be created');
371 }
372 }
373
374 /**
375 * Returns whether this {@link Converter} has been destroyed.
376 *
377 * @return {boolean} <code>true</code> if destroyed; otherwise <code>false</code>.
378 * @see {@link Converter#destroy}
379 * @public
380 */
381 get destroyed() {
382 return this[_destroyed];
383 }
384
385 /**
386 * Returns the {@link Provider} for this {@link Converter}.
387 *
388 * @return {Provider} The provider.
389 * @public
390 */
391 get provider() {
392 return this[_provider];
393 }
394
395}
396
397module.exports = Converter;
398
399/**
400 * The options that can be passed to {@link Converter#convertFile}.
401 *
402 * @typedef {Converter~ConvertOptions} Converter~ConvertFileOptions
403 * @property {string} [outputFilePath] - The path of the file to which the output should be written to. By default, this
404 * will be derived from the input file path.
405 */
406
407/**
408 * The options that can be passed to {@link Converter#convert}.
409 *
410 * @typedef {Object} Converter~ConvertOptions
411 * @property {string} [background] - The background color to be used to fill transparent regions within the SVG. If
412 * omitted, the {@link Provider} will determine the default background color.
413 * @property {string} [baseFile] - The path of the file to be converted into a file URL to use for all relative URLs
414 * contained within the SVG. Cannot be used in conjunction with the <code>baseUrl</code> option.
415 * @property {string} [baseUrl] - The base URL to use for all relative URLs contained within the SVG. Cannot be used in
416 * conjunction with the <code>baseFile</code> option.
417 * @property {number|string} [height] - The height of the output to be generated. If omitted, an attempt will be made to
418 * derive the height from the SVG input.
419 * @property {number} [scale=1] - The scale to be applied to the width and height (either specified as options or
420 * derived).
421 * @property {number|string} [width] - The width of the output to be generated. If omitted, an attempt will be made to
422 * derive the width from the SVG input.
423 */
424
425/**
426 * The options that can be passed to {@link Converter}.
427 *
428 * @typedef {Object} Converter~Options
429 * @property {Object} [puppeteer] - The options that are to be passed directly to <code>puppeteer.launch</code> when
430 * creating the <code>Browser</code> instance.
431 */