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 | ;
|
24 |
|
25 | /* global document: false */
|
26 |
|
27 | const fileUrl = require('file-url');
|
28 | const fs = require('fs');
|
29 | const path = require('path');
|
30 | const puppeteer = require('puppeteer');
|
31 | const tmp = require('tmp');
|
32 | const util = require('util');
|
33 |
|
34 | const readFile = util.promisify(fs.readFile);
|
35 | const writeFile = util.promisify(fs.writeFile);
|
36 |
|
37 | const _browser = Symbol('browser');
|
38 | const _convert = Symbol('convert');
|
39 | const _destroyed = Symbol('destroyed');
|
40 | const _getDimensions = Symbol('getDimensions');
|
41 | const _getPage = Symbol('getPage');
|
42 | const _getTempFile = Symbol('getTempFile');
|
43 | const _options = Symbol('options');
|
44 | const _page = Symbol('page');
|
45 | const _parseOptions = Symbol('parseOptions');
|
46 | const _provider = Symbol('provider');
|
47 | const _setDimensions = Symbol('setDimensions');
|
48 | const _tempFile = Symbol('tempFile');
|
49 | const _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 | */
|
72 | class 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; }
|
197 | html { 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 |
|
397 | module.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 | */
|