You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

408 lines
15 KiB

3 years ago
  1. (function() {
  2. const out$ = typeof exports != 'undefined' && exports || typeof define != 'undefined' && {} || this || window;
  3. if (typeof define !== 'undefined') define('save-svg-as-png', [], () => out$);
  4. out$.default = out$;
  5. const xmlNs = 'http://www.w3.org/2000/xmlns/';
  6. const xhtmlNs = 'http://www.w3.org/1999/xhtml';
  7. const svgNs = 'http://www.w3.org/2000/svg';
  8. const doctype = '<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [<!ENTITY nbsp "&#160;">]>';
  9. const urlRegex = /url\(["']?(.+?)["']?\)/;
  10. const fontFormats = {
  11. woff2: 'font/woff2',
  12. woff: 'font/woff',
  13. otf: 'application/x-font-opentype',
  14. ttf: 'application/x-font-ttf',
  15. eot: 'application/vnd.ms-fontobject',
  16. sfnt: 'application/font-sfnt',
  17. svg: 'image/svg+xml'
  18. };
  19. const isElement = obj => obj instanceof HTMLElement || obj instanceof SVGElement;
  20. const requireDomNode = el => {
  21. if (!isElement(el)) throw new Error(`an HTMLElement or SVGElement is required; got ${el}`);
  22. };
  23. const requireDomNodePromise = el =>
  24. new Promise((resolve, reject) => {
  25. if (isElement(el)) resolve(el)
  26. else reject(new Error(`an HTMLElement or SVGElement is required; got ${el}`));
  27. })
  28. const isExternal = url => url && url.lastIndexOf('http',0) === 0 && url.lastIndexOf(window.location.host) === -1;
  29. const getFontMimeTypeFromUrl = fontUrl => {
  30. const formats = Object.keys(fontFormats)
  31. .filter(extension => fontUrl.indexOf(`.${extension}`) > 0)
  32. .map(extension => fontFormats[extension]);
  33. if (formats) return formats[0];
  34. console.error(`Unknown font format for ${fontUrl}. Fonts may not be working correctly.`);
  35. return 'application/octet-stream';
  36. };
  37. const arrayBufferToBase64 = buffer => {
  38. let binary = '';
  39. const bytes = new Uint8Array(buffer);
  40. for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
  41. return window.btoa(binary);
  42. }
  43. const getDimension = (el, clone, dim) => {
  44. const v =
  45. (el.viewBox && el.viewBox.baseVal && el.viewBox.baseVal[dim]) ||
  46. (clone.getAttribute(dim) !== null && !clone.getAttribute(dim).match(/%$/) && parseInt(clone.getAttribute(dim))) ||
  47. el.getBoundingClientRect()[dim] ||
  48. parseInt(clone.style[dim]) ||
  49. parseInt(window.getComputedStyle(el).getPropertyValue(dim));
  50. return typeof v === 'undefined' || v === null || isNaN(parseFloat(v)) ? 0 : v;
  51. };
  52. const getDimensions = (el, clone, width, height) => {
  53. if (el.tagName === 'svg') return {
  54. width: width || getDimension(el, clone, 'width'),
  55. height: height || getDimension(el, clone, 'height')
  56. };
  57. else if (el.getBBox) {
  58. const {x, y, width, height} = el.getBBox();
  59. return {
  60. width: x + width,
  61. height: y + height
  62. };
  63. }
  64. };
  65. const reEncode = data =>
  66. decodeURIComponent(
  67. encodeURIComponent(data)
  68. .replace(/%([0-9A-F]{2})/g, (match, p1) => {
  69. const c = String.fromCharCode(`0x${p1}`);
  70. return c === '%' ? '%25' : c;
  71. })
  72. );
  73. const uriToBlob = uri => {
  74. const byteString = window.atob(uri.split(',')[1]);
  75. const mimeString = uri.split(',')[0].split(':')[1].split(';')[0]
  76. const buffer = new ArrayBuffer(byteString.length);
  77. const intArray = new Uint8Array(buffer);
  78. for (let i = 0; i < byteString.length; i++) {
  79. intArray[i] = byteString.charCodeAt(i);
  80. }
  81. return new Blob([buffer], {type: mimeString});
  82. };
  83. const query = (el, selector) => {
  84. if (!selector) return;
  85. try {
  86. return el.querySelector(selector) || el.parentNode && el.parentNode.querySelector(selector);
  87. } catch(err) {
  88. console.warn(`Invalid CSS selector "${selector}"`, err);
  89. }
  90. };
  91. const detectCssFont = (rule, href) => {
  92. // Match CSS font-face rules to external links.
  93. // @font-face {
  94. // src: local('Abel'), url(https://fonts.gstatic.com/s/abel/v6/UzN-iejR1VoXU2Oc-7LsbvesZW2xOQ-xsNqO47m55DA.woff2);
  95. // }
  96. const match = rule.cssText.match(urlRegex);
  97. const url = (match && match[1]) || '';
  98. if (!url || url.match(/^data:/) || url === 'about:blank') return;
  99. const fullUrl =
  100. url.startsWith('../') ? `${href}/../${url}`
  101. : url.startsWith('./') ? `${href}/.${url}`
  102. : url;
  103. return {
  104. text: rule.cssText,
  105. format: getFontMimeTypeFromUrl(fullUrl),
  106. url: fullUrl
  107. };
  108. };
  109. const inlineImages = el => Promise.all(
  110. Array.from(el.querySelectorAll('image')).map(image => {
  111. let href = image.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || image.getAttribute('href');
  112. if (!href) return Promise.resolve(null);
  113. if (isExternal(href)) {
  114. href += (href.indexOf('?') === -1 ? '?' : '&') + 't=' + new Date().valueOf();
  115. }
  116. return new Promise((resolve, reject) => {
  117. const canvas = document.createElement('canvas');
  118. const img = new Image();
  119. img.crossOrigin = 'anonymous';
  120. img.src = href;
  121. img.onerror = () => reject(new Error(`Could not load ${href}`));
  122. img.onload = () => {
  123. canvas.width = img.width;
  124. canvas.height = img.height;
  125. canvas.getContext('2d').drawImage(img, 0, 0);
  126. image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', canvas.toDataURL('image/png'));
  127. resolve(true);
  128. };
  129. });
  130. })
  131. );
  132. const cachedFonts = {};
  133. const inlineFonts = fonts => Promise.all(
  134. fonts.map(font =>
  135. new Promise((resolve, reject) => {
  136. if (cachedFonts[font.url]) return resolve(cachedFonts[font.url]);
  137. const req = new XMLHttpRequest();
  138. req.addEventListener('load', () => {
  139. // TODO: it may also be worth it to wait until fonts are fully loaded before
  140. // attempting to rasterize them. (e.g. use https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet)
  141. const fontInBase64 = arrayBufferToBase64(req.response);
  142. const fontUri = font.text.replace(urlRegex, `url("data:${font.format};base64,${fontInBase64}")`)+'\n';
  143. cachedFonts[font.url] = fontUri;
  144. resolve(fontUri);
  145. });
  146. req.addEventListener('error', e => {
  147. console.warn(`Failed to load font from: ${font.url}`, e);
  148. cachedFonts[font.url] = null;
  149. resolve(null);
  150. });
  151. req.addEventListener('abort', e => {
  152. console.warn(`Aborted loading font from: ${font.url}`, e);
  153. resolve(null);
  154. });
  155. req.open('GET', font.url);
  156. req.responseType = 'arraybuffer';
  157. req.send();
  158. })
  159. )
  160. ).then(fontCss => fontCss.filter(x => x).join(''));
  161. let cachedRules = null;
  162. const styleSheetRules = () => {
  163. if (cachedRules) return cachedRules;
  164. return cachedRules = Array.from(document.styleSheets).map(sheet => {
  165. try {
  166. return {rules: sheet.cssRules, href: sheet.href};
  167. } catch (e) {
  168. console.warn(`Stylesheet could not be loaded: ${sheet.href}`, e);
  169. return {};
  170. }
  171. });
  172. };
  173. const inlineCss = (el, options) => {
  174. const {
  175. selectorRemap,
  176. modifyStyle,
  177. modifyCss,
  178. fonts,
  179. excludeUnusedCss
  180. } = options || {};
  181. const generateCss = modifyCss || ((selector, properties) => {
  182. const sel = selectorRemap ? selectorRemap(selector) : selector;
  183. const props = modifyStyle ? modifyStyle(properties) : properties;
  184. return `${sel}{${props}}\n`;
  185. });
  186. const css = [];
  187. const detectFonts = typeof fonts === 'undefined';
  188. const fontList = fonts || [];
  189. styleSheetRules().forEach(({rules, href}) => {
  190. if (!rules) return;
  191. Array.from(rules).forEach(rule => {
  192. if (typeof rule.style != 'undefined') {
  193. if (query(el, rule.selectorText)) css.push(generateCss(rule.selectorText, rule.style.cssText));
  194. else if (detectFonts && rule.cssText.match(/^@font-face/)) {
  195. const font = detectCssFont(rule, href);
  196. if (font) fontList.push(font);
  197. } else if (!excludeUnusedCss) {
  198. css.push(rule.cssText);
  199. }
  200. }
  201. });
  202. });
  203. return inlineFonts(fontList).then(fontCss => css.join('\n') + fontCss);
  204. };
  205. const downloadOptions = () => {
  206. if (!navigator.msSaveOrOpenBlob && !('download' in document.createElement('a'))) {
  207. return {popup: window.open()};
  208. }
  209. };
  210. out$.prepareSvg = (el, options, done) => {
  211. requireDomNode(el);
  212. const {
  213. left = 0,
  214. top = 0,
  215. width: w,
  216. height: h,
  217. scale = 1,
  218. responsive = false,
  219. excludeCss = false,
  220. } = options || {};
  221. return inlineImages(el).then(() => {
  222. let clone = el.cloneNode(true);
  223. clone.style.backgroundColor = (options || {}).backgroundColor || el.style.backgroundColor;
  224. const {width, height} = getDimensions(el, clone, w, h);
  225. if (el.tagName !== 'svg') {
  226. if (el.getBBox) {
  227. if (clone.getAttribute('transform') != null) {
  228. clone.setAttribute('transform', clone.getAttribute('transform').replace(/translate\(.*?\)/, ''));
  229. }
  230. const svg = document.createElementNS('http://www.w3.org/2000/svg','svg');
  231. svg.appendChild(clone);
  232. clone = svg;
  233. } else {
  234. console.error('Attempted to render non-SVG element', el);
  235. return;
  236. }
  237. }
  238. clone.setAttribute('version', '1.1');
  239. clone.setAttribute('viewBox', [left, top, width, height].join(' '));
  240. if (!clone.getAttribute('xmlns')) clone.setAttributeNS(xmlNs, 'xmlns', svgNs);
  241. if (!clone.getAttribute('xmlns:xlink')) clone.setAttributeNS(xmlNs, 'xmlns:xlink', 'http://www.w3.org/1999/xlink');
  242. if (responsive) {
  243. clone.removeAttribute('width');
  244. clone.removeAttribute('height');
  245. clone.setAttribute('preserveAspectRatio', 'xMinYMin meet');
  246. } else {
  247. clone.setAttribute('width', width * scale);
  248. clone.setAttribute('height', height * scale);
  249. }
  250. Array.from(clone.querySelectorAll('foreignObject > *')).forEach(foreignObject => {
  251. foreignObject.setAttributeNS(xmlNs, 'xmlns', foreignObject.tagName === 'svg' ? svgNs : xhtmlNs);
  252. });
  253. if (excludeCss) {
  254. const outer = document.createElement('div');
  255. outer.appendChild(clone);
  256. const src = outer.innerHTML;
  257. if (typeof done === 'function') done(src, width, height);
  258. else return {src, width, height};
  259. } else {
  260. return inlineCss(el, options).then(css => {
  261. const style = document.createElement('style');
  262. style.setAttribute('type', 'text/css');
  263. style.innerHTML = `<![CDATA[\n${css}\n]]>`;
  264. const defs = document.createElement('defs');
  265. defs.appendChild(style);
  266. clone.insertBefore(defs, clone.firstChild);
  267. const outer = document.createElement('div');
  268. outer.appendChild(clone);
  269. const src = outer.innerHTML.replace(/NS\d+:href/gi, 'xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href');
  270. if (typeof done === 'function') done(src, width, height);
  271. else return {src, width, height};
  272. });
  273. }
  274. });
  275. };
  276. out$.svgAsDataUri = (el, options, done) => {
  277. requireDomNode(el);
  278. return out$.prepareSvg(el, options)
  279. .then(({src, width, height}) => {
  280. const svgXml = `data:image/svg+xml;base64,${window.btoa(reEncode(doctype+src))}`;
  281. if (typeof done === 'function') {
  282. done(svgXml, width, height);
  283. }
  284. return svgXml;
  285. });
  286. };
  287. out$.svgAsPngUri = (el, options, done) => {
  288. requireDomNode(el);
  289. const {
  290. encoderType = 'image/png',
  291. encoderOptions = 0.8,
  292. canvg
  293. } = options || {};
  294. const convertToPng = ({src, width, height}) => {
  295. const canvas = document.createElement('canvas');
  296. const context = canvas.getContext('2d');
  297. const pixelRatio = window.devicePixelRatio || 1;
  298. canvas.width = width * pixelRatio;
  299. canvas.height = height * pixelRatio;
  300. canvas.style.width = `${canvas.width}px`;
  301. canvas.style.height = `${canvas.height}px`;
  302. context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
  303. if (canvg) canvg(canvas, src);
  304. else context.drawImage(src, 0, 0);
  305. let png;
  306. try {
  307. png = canvas.toDataURL(encoderType, encoderOptions);
  308. } catch (e) {
  309. if ((typeof SecurityError !== 'undefined' && e instanceof SecurityError) || e.name === 'SecurityError') {
  310. console.error('Rendered SVG images cannot be downloaded in this browser.');
  311. return;
  312. } else throw e;
  313. }
  314. if (typeof done === 'function') done(png, canvas.width, canvas.height);
  315. return Promise.resolve(png);
  316. }
  317. if (canvg) return out$.prepareSvg(el, options).then(convertToPng);
  318. else return out$.svgAsDataUri(el, options).then(uri => {
  319. return new Promise((resolve, reject) => {
  320. const image = new Image();
  321. image.onload = () => resolve(convertToPng({
  322. src: image,
  323. width: image.width,
  324. height: image.height
  325. }));
  326. image.onerror = () => {
  327. reject(`There was an error loading the data URI as an image on the following SVG\n${window.atob(uri.slice(26))}Open the following link to see browser's diagnosis\n${uri}`);
  328. }
  329. image.src = uri;
  330. })
  331. });
  332. };
  333. out$.download = (name, uri, options) => {
  334. if (navigator.msSaveOrOpenBlob) navigator.msSaveOrOpenBlob(uriToBlob(uri), name);
  335. else {
  336. const saveLink = document.createElement('a');
  337. if ('download' in saveLink) {
  338. saveLink.download = name;
  339. saveLink.style.display = 'none';
  340. document.body.appendChild(saveLink);
  341. try {
  342. const blob = uriToBlob(uri);
  343. const url = URL.createObjectURL(blob);
  344. saveLink.href = url;
  345. saveLink.onclick = () => requestAnimationFrame(() => URL.revokeObjectURL(url));
  346. } catch (e) {
  347. console.error(e);
  348. console.warn('Error while getting object URL. Falling back to string URL.');
  349. saveLink.href = uri;
  350. }
  351. saveLink.click();
  352. document.body.removeChild(saveLink);
  353. } else if (options && options.popup) {
  354. options.popup.document.title = name;
  355. options.popup.location.replace(uri);
  356. }
  357. }
  358. };
  359. out$.saveSvg = (el, name, options) => {
  360. const downloadOpts = downloadOptions(); // don't inline, can't be async
  361. return requireDomNodePromise(el)
  362. .then(el => out$.svgAsDataUri(el, options || {}))
  363. .then(uri => out$.download(name, uri, downloadOpts));
  364. };
  365. out$.saveSvgAsPng = (el, name, options) => {
  366. const downloadOpts = downloadOptions(); // don't inline, can't be async
  367. return requireDomNodePromise(el)
  368. .then(el => out$.svgAsPngUri(el, options || {}))
  369. .then(uri => out$.download(name, uri, downloadOpts));
  370. };
  371. })();